relay-management-latest #75

Merged
y merged 9 commits from relay-management-latest into main 2024-05-24 13:48:01 +00:00
23 changed files with 1490 additions and 29 deletions

View File

@ -10,17 +10,24 @@ import {
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux' import { useDispatch, useSelector } from 'react-redux'
import { setAuthState, setMetadataEvent } from '../../store/actions' import {
setAuthState,
setMetadataEvent,
userLogOutAction
} from '../../store/actions'
import { State } from '../../store/rootReducer' import { State } from '../../store/rootReducer'
import { Dispatch } from '../../store/store' import { Dispatch } from '../../store/store'
import Username from '../username' import Username from '../username'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { MetadataController, NostrController } from '../../controllers' import { MetadataController, NostrController } from '../../controllers'
import { appPublicRoutes, getProfileRoute } from '../../routes' import {
appPublicRoutes,
appPrivateRoutes,
getProfileRoute
} from '../../routes'
import { import {
clearAuthToken, clearAuthToken,
clearState,
saveNsecBunkerDelegatedKey, saveNsecBunkerDelegatedKey,
shorten shorten
} from '../../utils' } from '../../utils'
@ -92,7 +99,8 @@ export const AppBar = () => {
// clear authToken saved in local storage // clear authToken saved in local storage
clearAuthToken() clearAuthToken()
clearState()
dispatch(userLogOutAction())
// update nsecBunker delegated key after logout // update nsecBunker delegated key after logout
const nostrController = NostrController.getInstance() const nostrController = NostrController.getInstance()
@ -160,6 +168,16 @@ export const AppBar = () => {
> >
Profile Profile
</MenuItem> </MenuItem>
<MenuItem
onClick={() => {
navigate(appPrivateRoutes.relays)
}}
sx={{
justifyContent: 'center'
}}
>
Relays
</MenuItem>
<Link <Link
to={appPublicRoutes.help} to={appPublicRoutes.help}
target="_blank" target="_blank"

View File

@ -1,13 +1,18 @@
import { EventTemplate } from 'nostr-tools' import { EventTemplate } from 'nostr-tools'
import { MetadataController, NostrController } from '.' import { MetadataController, NostrController } from '.'
import { setAuthState, setMetadataEvent } from '../store/actions' import {
setAuthState,
setMetadataEvent,
setRelayMapAction
} from '../store/actions'
import store from '../store/store' import store from '../store/store'
import { import {
base64DecodeAuthToken, base64DecodeAuthToken,
base64EncodeSignedEvent, base64EncodeSignedEvent,
getAuthToken, getAuthToken,
getVisitedLink, getVisitedLink,
saveAuthToken saveAuthToken,
compareObjects
} from '../utils' } from '../utils'
import { appPrivateRoutes } from '../routes' import { appPrivateRoutes } from '../routes'
import { SignedEvent } from '../types' import { SignedEvent } from '../types'
@ -30,7 +35,7 @@ export class AuthController {
* @returns url to redirect if authentication successfull * @returns url to redirect if authentication successfull
* or error if otherwise * or error if otherwise
*/ */
async authenticateAndFindMetadata(pubkey: string) { async authAndGetMetadataAndRelaysMap(pubkey: string) {
const emptyMetadata = this.metadataController.getEmptyMetadataEvent() const emptyMetadata = this.metadataController.getEmptyMetadataEvent()
this.metadataController this.metadataController
@ -69,6 +74,18 @@ export class AuthController {
}) })
) )
const relayMap = await this.nostrController.getRelayMap(pubkey)
if (Object.keys(relayMap).length < 1) {
// Navigate user to relays page if relay map is empty
return Promise.resolve(appPrivateRoutes.relays)
}
if (store.getState().auth?.loggedIn) {
if (!compareObjects(store.getState().relays?.map, relayMap.map))
store.dispatch(setRelayMapAction(relayMap.map))
}
const visitedLink = getVisitedLink() const visitedLink = getVisitedLink()
if (visitedLink) { if (visitedLink) {

View File

@ -3,23 +3,45 @@ import NDK, {
NDKNip46Signer, NDKNip46Signer,
NDKPrivateKeySigner, NDKPrivateKeySigner,
NDKUser, NDKUser,
NostrEvent NostrEvent,
NDKSubscription
} from '@nostr-dev-kit/ndk' } from '@nostr-dev-kit/ndk'
import { import {
Event, Event,
EventTemplate, EventTemplate,
SimplePool, SimplePool,
UnsignedEvent, UnsignedEvent,
Filter,
Relay,
finalizeEvent, finalizeEvent,
nip04, nip04,
nip19 nip19,
kinds
} from 'nostr-tools' } from 'nostr-tools'
import { EventEmitter } from 'tseep' import { EventEmitter } from 'tseep'
import { updateNsecbunkerPubkey } from '../store/actions' import {
updateNsecbunkerPubkey,
setMostPopularRelaysAction,
setRelayInfoAction,
setRelayConnectionStatusAction
} 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 { SignedEvent } from '../types' import {
import { getNsecBunkerDelegatedKey, verifySignedEvent } from '../utils' SignedEvent,
RelayMap,
RelayStats,
ReadRelay,
RelayInfoObject,
RelayConnectionStatus,
RelayConnectionState
} from '../types'
import {
compareObjects,
getNsecBunkerDelegatedKey,
verifySignedEvent
} from '../utils'
import axios from 'axios'
export class NostrController extends EventEmitter { export class NostrController extends EventEmitter {
private static instance: NostrController private static instance: NostrController
@ -27,6 +49,8 @@ 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()
} }
@ -216,12 +240,16 @@ export class NostrController extends EventEmitter {
if (publishedRelays.length === 0) { if (publishedRelays.length === 0) {
const failedPublishes: any[] = [] const failedPublishes: any[] = []
const fallbackRejectionReason =
'Attempt to publish an event has been rejected with unknown reason.'
results.forEach((res, index) => { results.forEach((res, index) => {
if (res.status === 'rejected') { if (res.status === 'rejected') {
failedPublishes.push({ failedPublishes.push({
relay: relays[index], relay: relays[index],
error: res.reason.message error: res.reason
? res.reason.message || fallbackRejectionReason
: fallbackRejectionReason
}) })
} }
}) })
@ -374,4 +402,359 @@ 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 this.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.reject('User relays were not found.')
}
}
/**
* 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 = Math.floor(Date.now() / 1000)
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 this.getMostPopularRelays()
}
const publishResult = await this.publishEvent(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.')
}
/**
* 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: ReadRelay) => 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
*/
getRelayInfo = async (relayURIs: string[]) => {
// initialize job request
const jobEventTemplate: EventTemplate = {
content: '',
created_at: Math.round(Date.now() / 1000),
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 this.publishEvent(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))
}
}
}
} }

1
src/hooks/index.ts Normal file
View File

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

6
src/hooks/store.ts Normal file
View File

@ -0,0 +1,6 @@
import { useDispatch, useSelector } from 'react-redux'
import type { Dispatch, RootState } from '../store/store'
// Use instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = useDispatch.withTypes<Dispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()

View File

@ -15,7 +15,8 @@ store.subscribe(
saveState({ saveState({
auth: store.getState().auth, auth: store.getState().auth,
metadata: store.getState().metadata, metadata: store.getState().metadata,
userRobotImage: store.getState().userRobotImage userRobotImage: store.getState().userRobotImage,
relays: store.getState().relays
}) })
}, 1000) }, 1000)
) )

View File

@ -80,7 +80,7 @@ export const Login = () => {
setLoadingSpinnerDesc('Authenticating and finding metadata') setLoadingSpinnerDesc('Authenticating and finding metadata')
const redirectPath = const redirectPath =
await authController.authenticateAndFindMetadata(pubkey) await authController.authAndGetMetadataAndRelaysMap(pubkey)
navigateAfterLogin(redirectPath) navigateAfterLogin(redirectPath)
}) })
@ -134,7 +134,7 @@ export const Login = () => {
setLoadingSpinnerDesc('Authenticating and finding metadata') setLoadingSpinnerDesc('Authenticating and finding metadata')
const redirectPath = await authController const redirectPath = await authController
.authenticateAndFindMetadata(publickey) .authAndGetMetadataAndRelaysMap(publickey)
.catch((err) => { .catch((err) => {
toast.error('Error occurred in authentication: ' + err) toast.error('Error occurred in authentication: ' + err)
return null return null
@ -229,7 +229,7 @@ export const Login = () => {
setLoadingSpinnerDesc('Authenticating and finding metadata') setLoadingSpinnerDesc('Authenticating and finding metadata')
const redirectPath = await authController const redirectPath = await authController
.authenticateAndFindMetadata(pubkey!) .authAndGetMetadataAndRelaysMap(pubkey!)
.catch((err) => { .catch((err) => {
toast.error('Error occurred in authentication: ' + err) toast.error('Error occurred in authentication: ' + err)
return null return null
@ -289,7 +289,7 @@ export const Login = () => {
setLoadingSpinnerDesc('Authenticating and finding metadata') setLoadingSpinnerDesc('Authenticating and finding metadata')
const redirectPath = await authController const redirectPath = await authController
.authenticateAndFindMetadata(pubkey!) .authAndGetMetadataAndRelaysMap(pubkey!)
.catch((err) => { .catch((err) => {
toast.error('Error occurred in authentication: ' + err) toast.error('Error occurred in authentication: ' + err)
return null return null

512
src/pages/relays/index.tsx Normal file
View File

@ -0,0 +1,512 @@
import { useEffect, useState } from 'react'
import { Box, List, ListItem, TextField } from '@mui/material'
import RouterIcon from '@mui/icons-material/Router'
import styles from './style.module.scss'
import Switch from '@mui/material/Switch'
import ListItemText from '@mui/material/ListItemText'
import Divider from '@mui/material/Divider'
import { NostrController } from '../../controllers'
import {
RelayMap,
RelayInfoObject,
RelayFee,
RelayConnectionState
} from '../../types'
import LogoutIcon from '@mui/icons-material/Logout'
import { useAppSelector, useAppDispatch } from '../../hooks'
import {
compareObjects,
shorten,
hexToNpub,
capitalizeFirstLetter
} from '../../utils'
import {
setRelayMapAction,
setRelayMapUpdatedAction
} from '../../store/actions'
import { toast } from 'react-toastify'
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import ElectricBoltIcon from '@mui/icons-material/ElectricBolt'
import { Tooltip } from '@mui/material'
import InputAdornment from '@mui/material/InputAdornment'
import Button from '@mui/material/Button'
export const RelaysPage = () => {
const nostrController = NostrController.getInstance()
const relaysState = useAppSelector((state) => state.relays)
const usersPubkey = useAppSelector((state) => state.auth?.usersPubkey)
const dispatch = useAppDispatch()
const [newRelayURI, setNewRelayURI] = useState<string>()
const [newRelayURIerror, setNewRelayURIerror] = useState<string>()
const [relayMap, setRelayMap] = useState<RelayMap | undefined>(
relaysState?.map
)
const [relaysInfo, setRelaysInfo] = useState<RelayInfoObject | undefined>(
relaysState?.info
)
const [displayRelaysInfo, setDisplayRelaysInfo] = useState<string[]>([])
const [relaysConnectionStatus, setRelaysConnectionStatus] = useState(
relaysState?.connectionStatus
)
const webSocketPrefix = 'wss://'
// Update relay connection status
useEffect(() => {
if (
!compareObjects(relaysConnectionStatus, relaysState?.connectionStatus)
) {
setRelaysConnectionStatus(relaysState?.connectionStatus)
}
}, [relaysConnectionStatus, relaysState?.connectionStatus])
useEffect(() => {
if (!compareObjects(relaysInfo, relaysState?.info)) {
setRelaysInfo(relaysState?.info)
}
}, [relaysInfo, relaysState?.info])
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 > 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(() => {
// Display notification if an empty relay map has been received
if (relayMap && Object.keys(relayMap).length === 0) {
relayRequirementWarning()
}
}, [relayMap])
const relayRequirementWarning = () =>
toast.warning('At least one write relay is needed for SIGit to work.')
const handleLeaveRelay = async (relay: string) => {
if (relayMap) {
const relaysInMap = Object.keys(relayMap).length
const writeRelays = Object.keys(relayMap).filter(
(key) => relayMap[key].write
)
// Check if at least one write relay is present in relay map
if (
relaysInMap <= 1 ||
(writeRelays.length === 1 && writeRelays.includes(relay))
) {
relayRequirementWarning()
} else {
const relayMapCopy = JSON.parse(JSON.stringify(relayMap))
// Remove relay from relay map
delete relayMapCopy[relay]
if (usersPubkey) {
// Publish updated relay map.
const relayMapPublishingRes = await nostrController
.publishRelayMap(relayMapCopy, usersPubkey, [relay])
.catch((err) => handlePublishRelayMapError(err))
if (relayMapPublishingRes) {
toast.success(relayMapPublishingRes)
setRelayMap(relayMapCopy)
dispatch(setRelayMapAction(relayMapCopy))
}
}
nostrController.disconnectFromRelays([relay])
}
}
}
const handlePublishRelayMapError = (err: any) => {
const errorPrefix = 'Error while publishing Relay Map'
if (Array.isArray(err)) {
err.forEach((errorObj: { relay: string; error: string }) => {
toast.error(
`${errorPrefix} to ${errorObj.relay}. Error: ${errorObj.error || 'Unknown'}`
)
})
} else {
toast.error(`${errorPrefix}. Error: ${err.message || 'Unknown'}`)
}
}
const handleRelayWriteChange = async (
relay: string,
event: React.ChangeEvent<HTMLInputElement>
) => {
if (relayMap && relayMap[relay]) {
if (
!event.target.checked &&
Object.keys(relayMap).filter((relay) => relayMap[relay].write)
.length === 1
) {
relayRequirementWarning()
} else {
const relayMapCopy = JSON.parse(JSON.stringify(relayMap))
relayMapCopy[relay].write = event.target.checked
if (usersPubkey) {
// Publish updated relay map
const relayMapPublishingRes = await nostrController
.publishRelayMap(relayMapCopy, usersPubkey)
.catch((err) => handlePublishRelayMapError(err))
if (relayMapPublishingRes) {
toast.success(relayMapPublishingRes)
setRelayMap(relayMapCopy)
dispatch(setRelayMapAction(relayMapCopy))
}
}
}
}
}
const handleAddNewRelay = async () => {
const relayURI = `${webSocketPrefix}${newRelayURI?.trim().replace(webSocketPrefix, '')}`
// Check if new relay URI is a valid string
if (
relayURI &&
!/^wss:\/\/[-a-zA-Z0-9@:%._\\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}/.test(
relayURI
)
) {
if (relayURI !== webSocketPrefix) {
setNewRelayURIerror(
'New relay URI is not valid. Example of valid relay URI: wss://sigit.relay.io'
)
}
} else if (relayURI && usersPubkey) {
const connectionStatus = await nostrController.connectToRelays([relayURI])
if (
connectionStatus &&
connectionStatus[relayURI] &&
connectionStatus[relayURI] === RelayConnectionState.Connected
) {
const relayMapCopy = JSON.parse(JSON.stringify(relayMap))
relayMapCopy[relayURI] = { write: true, read: true }
// Publish updated relay map
const relayMapPublishingRes = await nostrController
.publishRelayMap(relayMapCopy, usersPubkey)
.catch((err) => handlePublishRelayMapError(err))
if (relayMapPublishingRes) {
setRelayMap(relayMapCopy)
setNewRelayURI('')
dispatch(setRelayMapAction(relayMapCopy))
nostrController.getRelayInfo([relayURI])
toast.success(relayMapPublishingRes)
}
setNewRelayURIerror(undefined)
} else {
toast.error(`Relay '${relayURI}' wasn't added.`)
setNewRelayURIerror(`Connection to '${relayURI}' was unsuccessful.`)
}
}
}
// 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 (
<Box className={styles.container}>
<Box className={styles.relayAddContainer}>
<TextField
label="Add new relay"
value={newRelayURI}
onChange={(e) => setNewRelayURI(e.target.value)}
helperText={newRelayURIerror}
error={!!newRelayURIerror}
InputProps={{
startAdornment: (
<InputAdornment position="start">
{webSocketPrefix}
</InputAdornment>
)
}}
className={styles.relayURItextfield}
/>
<Button variant="contained" onClick={() => handleAddNewRelay()}>
Add
</Button>
</Box>
<Box className={styles.sectionTitle}>
<RouterIcon className={styles.sectionIcon} />
<span>YOUR RELAYS</span>
</Box>
{relayMap && (
<Box className={styles.relaysContainer}>
{Object.keys(relayMap).map((relay, i) => (
<Box className={styles.relay} key={`relay_${i}`}>
<List>
<ListItem>
<span
className={[
styles.connectionStatus,
relaysConnectionStatus
? 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>
)
}

View File

@ -0,0 +1,106 @@
@import '../../colors.scss';
.container {
margin-top: 25px;
.relayURItextfield {
width: 100%;
}
.relayAddContainer {
display: flex;
flex-direction: row;
gap: 10px;
width: 100%;
}
.sectionIcon {
font-size: 30px;
}
.sectionTitle {
margin-top: 35px;
margin-bottom: 10px;
display: flex;
flex-direction: row;
gap: 5px;
font-size: 1.5rem;
line-height: 2rem;
font-weight: 600;
}
.relaysContainer {
display: flex;
flex-direction: column;
gap: 15px;
}
.relay {
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 4px;
.relayDivider {
margin-left: 10px;
margin-right: 10px;
}
.leaveRelayContainer {
display: flex;
flex-direction: row;
gap: 10px;
cursor: pointer;
}
.showInfo {
cursor: pointer;
}
.showInfoIcon {
margin-right: 3px;
margin-bottom: auto;
vertical-align: middle;
}
.relayInfoContainer {
display: flex;
flex-direction: column;
gap: 5px;
text-wrap: wrap;
}
.relayInfoTitle {
font-weight: 600;
}
.relayInfoSubTitle {
font-weight: 500;
}
.copyItem {
margin-left: 10px;
color: #34495e;
vertical-align: bottom;
cursor: pointer;
}
.connectionStatus {
border-radius: 9999px;
width: 10px;
height: 10px;
margin-right: 5px;
margin-top: 2px;
}
.connectionStatusConnected {
background-color: $review-feedback-correct;
}
.connectionStatusNotConnected {
background-color: $review-feedback-incorrect;
}
.connectionStatusUnknown {
background-color: $input-text-color;
}
}
}

View File

@ -6,14 +6,15 @@ import { ProfilePage } from '../pages/profile'
import { hexToNpub } from '../utils' import { hexToNpub } from '../utils'
import { SignPage } from '../pages/sign' import { SignPage } from '../pages/sign'
import { VerifyPage } from '../pages/verify' import { VerifyPage } from '../pages/verify'
import { ProfileSettingsPage } from '../pages/settings/profile' import { RelaysPage } from '../pages/relays'
export const appPrivateRoutes = { export const appPrivateRoutes = {
homePage: '/', homePage: '/',
create: '/create', create: '/create',
sign: '/sign', sign: '/sign',
verify: '/verify', verify: '/verify',
profileSettings: '/settings/profile/:npub' profileSettings: '/settings/profile/:npub',
relays: '/relays'
} }
export const appPublicRoutes = { export const appPublicRoutes = {
@ -65,6 +66,10 @@ export const privateRoutes = [
}, },
{ {
path: appPrivateRoutes.profileSettings, path: appPrivateRoutes.profileSettings,
element: <ProfileSettingsPage /> element: <ProfilePage />
},
{
path: appPrivateRoutes.relays,
element: <RelaysPage />
} }
] ]

View File

@ -1,5 +1,7 @@
export const RESTORE_STATE = 'RESTORE_STATE' export const RESTORE_STATE = 'RESTORE_STATE'
export const USER_LOGOUT = 'USER_LOGOUT'
export const SET_AUTH_STATE = 'SET_AUTH_STATE' export const SET_AUTH_STATE = 'SET_AUTH_STATE'
export const UPDATE_LOGIN_METHOD = 'UPDATE_LOGIN_METHOD' export const UPDATE_LOGIN_METHOD = 'UPDATE_LOGIN_METHOD'
export const UPDATE_KEYPAIR = 'UPDATE_KEYPAIR' export const UPDATE_KEYPAIR = 'UPDATE_KEYPAIR'
@ -9,3 +11,9 @@ export const UPDATE_NSECBUNKER_RELAYS = 'UPDATE_NSECBUNKER_RELAYS'
export const SET_METADATA_EVENT = 'SET_METADATA_EVENT' export const SET_METADATA_EVENT = 'SET_METADATA_EVENT'
export const SET_USER_ROBOT_IMAGE = 'SET_USER_ROBOT_IMAGE' export const SET_USER_ROBOT_IMAGE = 'SET_USER_ROBOT_IMAGE'
export const SET_RELAY_MAP = 'SET_RELAY_MAP'
export const SET_RELAY_INFO = 'SET_RELAY_INFO'
export const SET_RELAY_MAP_UPDATED = 'SET_RELAY_MAP_UPDATED'
export const SET_MOST_POPULAR_RELAYS = 'SET_MOST_POPULAR_RELAYS'
export const SET_RELAY_CONNECTION_STATUS = 'SET_RELAY_CONNECTION_STATUS'

View File

@ -3,6 +3,7 @@ import { State } from './rootReducer'
export * from './auth/action' export * from './auth/action'
export * from './metadata/action' export * from './metadata/action'
export * from './relays/action'
export const restoreState = (payload: State) => { export const restoreState = (payload: State) => {
return { return {
@ -15,3 +16,9 @@ export interface RestoreState {
type: typeof ActionTypes.RESTORE_STATE type: typeof ActionTypes.RESTORE_STATE
payload: State payload: State
} }
export const userLogOutAction = () => {
return {
type: ActionTypes.USER_LOGOUT
}
}

View File

@ -0,0 +1,39 @@
import * as ActionTypes from '../actionTypes'
import {
SetRelayMapAction,
SetMostPopularRelaysAction,
SetRelayInfoAction,
SetRelayConnectionStatusAction,
SetRelayMapUpdatedAction
} from './types'
import { RelayMap, RelayInfoObject, RelayConnectionStatus } from '../../types'
export const setRelayMapAction = (payload: RelayMap): SetRelayMapAction => ({
type: ActionTypes.SET_RELAY_MAP,
payload
})
export const setRelayInfoAction = (
payload: RelayInfoObject
): SetRelayInfoAction => ({
type: ActionTypes.SET_RELAY_INFO,
payload
})
export const setMostPopularRelaysAction = (
payload: string[]
): SetMostPopularRelaysAction => ({
type: ActionTypes.SET_MOST_POPULAR_RELAYS,
payload
})
export const setRelayConnectionStatusAction = (
payload: RelayConnectionStatus
): SetRelayConnectionStatusAction => ({
type: ActionTypes.SET_RELAY_CONNECTION_STATUS,
payload
})
export const setRelayMapUpdatedAction = (): SetRelayMapUpdatedAction => ({
type: ActionTypes.SET_RELAY_MAP_UPDATED
})

View File

@ -0,0 +1,46 @@
import * as ActionTypes from '../actionTypes'
import { RelaysDispatchTypes, RelaysState } from './types'
const initialState: RelaysState = {
map: undefined,
mapUpdated: undefined,
mostPopular: undefined,
info: undefined,
connectionStatus: undefined
}
const reducer = (
state = initialState,
action: RelaysDispatchTypes
): RelaysState | null => {
switch (action.type) {
case ActionTypes.SET_RELAY_MAP:
return { ...state, map: action.payload, mapUpdated: Date.now() }
case ActionTypes.SET_RELAY_MAP_UPDATED:
return { ...state, mapUpdated: Date.now() }
case ActionTypes.SET_RELAY_INFO:
return {
...state,
info: { ...state.info, ...action.payload }
}
case ActionTypes.SET_RELAY_CONNECTION_STATUS:
return {
...state,
connectionStatus: action.payload
}
case ActionTypes.SET_MOST_POPULAR_RELAYS:
return { ...state, mostPopular: action.payload }
case ActionTypes.RESTORE_STATE:
return action.payload.relays
default:
return state
}
}
export default reducer

43
src/store/relays/types.ts Normal file
View File

@ -0,0 +1,43 @@
import * as ActionTypes from '../actionTypes'
import { RestoreState } from '../actions'
import { RelayMap, RelayInfoObject, RelayConnectionStatus } from '../../types'
export type RelaysState = {
map?: RelayMap
mapUpdated?: number
mostPopular?: string[]
info?: RelayInfoObject
connectionStatus?: RelayConnectionStatus
}
export interface SetRelayMapAction {
type: typeof ActionTypes.SET_RELAY_MAP
payload: RelayMap
}
export interface SetMostPopularRelaysAction {
type: typeof ActionTypes.SET_MOST_POPULAR_RELAYS
payload: string[]
}
export interface SetRelayInfoAction {
type: typeof ActionTypes.SET_RELAY_INFO
payload: RelayInfoObject
}
export interface SetRelayConnectionStatusAction {
type: typeof ActionTypes.SET_RELAY_CONNECTION_STATUS
payload: RelayConnectionStatus
}
export interface SetRelayMapUpdatedAction {
type: typeof ActionTypes.SET_RELAY_MAP_UPDATED
}
export type RelaysDispatchTypes =
| SetRelayMapAction
| SetRelayInfoAction
| SetRelayMapUpdatedAction
| SetMostPopularRelaysAction
| SetRelayConnectionStatusAction
| RestoreState

View File

@ -4,15 +4,31 @@ import authReducer from './auth/reducer'
import { AuthState } from './auth/types' import { AuthState } from './auth/types'
import metadataReducer from './metadata/reducer' import metadataReducer from './metadata/reducer'
import userRobotImageReducer from './userRobotImage/reducer' import userRobotImageReducer from './userRobotImage/reducer'
import { RelaysState } from './relays/types'
import relaysReducer from './relays/reducer'
import * as ActionTypes from './actionTypes'
export interface State { export interface State {
auth: AuthState auth: AuthState
metadata?: Event metadata?: Event
userRobotImage?: string userRobotImage?: string
relays: RelaysState
} }
export default combineReducers({ export const appReducer = combineReducers({
Review

Have you addressed the case where it was being imported as default import?

Have you addressed the case where it was being imported as default import?
Review

yes

yes
auth: authReducer, auth: authReducer,
metadata: metadataReducer, metadata: metadataReducer,
userRobotImage: userRobotImageReducer userRobotImage: userRobotImageReducer,
relays: relaysReducer
}) })
// FIXME: define types
export default (state: any, action: any) => {
switch (action.type) {
case ActionTypes.USER_LOGOUT:
return appReducer(undefined, action)
default:
return appReducer(state, action)
}
}

View File

@ -1,8 +1,11 @@
import { configureStore } from '@reduxjs/toolkit' import { configureStore } from '@reduxjs/toolkit'
import rootReducer from './rootReducer' import rootReducer from './rootReducer'
const store = configureStore({ reducer: rootReducer }) const store = configureStore({
reducer: rootReducer
})
export default store export default store
export type Dispatch = typeof store.dispatch export type Dispatch = typeof store.dispatch
export type RootState = ReturnType<typeof store.getState>

View File

@ -2,3 +2,4 @@ export * from './core'
export * from './nostr' export * from './nostr'
export * from './profile' export * from './profile'
export * from './zip' export * from './zip'
export * from './relay'

View File

@ -8,11 +8,6 @@ export interface SignedEvent {
sig: string sig: string
} }
export interface RelaySet {
read: string[]
write: string[]
}
export interface NostrJoiningBlock { export interface NostrJoiningBlock {
block: number block: number
encodedEventPointer: string encodedEventPointer: string

232
src/types/relay.ts Normal file
View File

@ -0,0 +1,232 @@
export interface RelaySet {
read: string[]
write: string[]
}
export type RelayMap = {
[key: string]: {
read: boolean
write: boolean
}
}
export interface RelayStats {
relays: number
pubKeys: number
users: number
trusted_users: number
events: number
posts: number
zaps: number
zap_amount: number
daily: Daily
daily_totals: DailyTotals
relay_stats: RelayStats
}
export interface RelayStats {
user_picks: UserPicks
written: Written
}
export interface Written {
last_week: LastWeek[]
}
export interface LastWeek {
d: string
p: number
ps: number
e: number
es: number
}
export interface UserPicks {
read_relays: ReadRelay[]
write_relays: ReadRelay[]
}
export interface ReadRelay {
d: string
r: number
w: number
rs: number
ws: number
}
export interface DailyTotals {
datasets: Datasets2
}
export interface Datasets2 {
kind_0: Kind0[]
kind_1: Kind0[]
kind_2: Kind0[]
kind_3: Kind0[]
kind_5: Kind0[]
kind_6: Kind0[]
kind_7: Kind0[]
kind_1984: Kind0[]
kind_9735: Kind0[]
kind_1063: Kind0[]
kind_6969: Kind0[]
kind_9802: Kind0[]
kind_30000: Kind0[]
kind_30001: Kind0[]
kind_30008: Kind0[]
kind_30009: Kind0[]
kind_30017: Kind0[]
kind_30018: Kind0[]
kind_30023: Kind0[]
kind_31337: Kind0[]
totals: Kind0[]
new_profiles: Kind0[]
new_pubkeys: Kind0[]
new_contact_lists: Kind0[]
new_ln: Kind0[]
new_users: Kind0[]
total_zap_amount: Kind0[]
zappers: Kind0[]
zapped_pubkeys: Kind0[]
zapped_events: Kind0[]
zap_providers: Kind0[]
}
export interface Daily {
datasets: Datasets
}
export interface Datasets {
kind_0: Kind0[]
kind_1: Kind0[]
kind_2: Kind0[]
kind_3: Kind0[]
kind_5: Kind0[]
kind_6: Kind0[]
kind_7: Kind0[]
kind_1984: Kind0[]
kind_9735: Kind0[]
kind_1063: Kind0[]
kind_6969: Kind0[]
kind_9802: Kind0[]
kind_30000: Kind0[]
kind_30001: Kind0[]
kind_30008: Kind0[]
kind_30009: Kind0[]
kind_30017: Kind0[]
kind_30018: Kind0[]
kind_30023: Kind0[]
kind_31337: Kind0[]
totals: Kind0[]
new_profiles: Kind0[]
new_pubkeys: Kind0[]
new_contact_lists: Kind0[]
new_ln: Kind0[]
new_users: Kind0[]
max_zap_amount: Kind0[]
avg_zap_amount: Kind0[]
total_zap_amount: Kind0[]
active_pubkeys: Kind0[]
active_pubkeys_total: Kind0[]
active_pubkeys_week: Kind0[]
active_pubkeys_total_week: Kind0[]
active_relays: Kind0[]
zappers: Kind0[]
zapped_pubkeys: Kind0[]
zapped_events: Kind0[]
zap_providers: Kind0[]
retention: Retention
}
export interface Retention {
all: All[]
tr: All[]
bio: All[]
all_curves: Allcurve[]
tr_curves: Allcurve[]
bio_curves: Allcurve[]
}
export interface Allcurve {
day: number
'2023-02': number
'2023-03': number
'2023-04': number
'2023-05': number
'2023-06': number
'2023-07': number
}
export interface All {
d: string
signups: number
retained: number
retd_posts: number
retd_replies: number
retd_reposts: number
retd_likes: number
retd_liked: number
retd_liked_pubkeys: number
retd_replied: number
retd_replied_pubkeys: number
retd_zaps_received: number
retd_zaps_received_msats: number
retd_zaps_sent: number
retd_zaps_sent_msats: number
retd_following: number
retd_followers: number
lost_posts: number
lost_replies: number
lost_reposts: number
lost_likes: number
lost_liked: number
lost_liked_pubkeys: number
lost_replied: number
lost_replied_pubkeys: number
lost_zaps_received: number
lost_zaps_received_msats: number
lost_zaps_sent: number
lost_zaps_sent_msats: number
lost_following: number
lost_followers: number
}
export interface Kind0 {
d: string
c: number
}
export interface RelayFee {
amount: number
unit: string
}
export interface RelayInfo {
name: string
description: string
pubkey: string
contact: string
supported_nips: number[]
software: string
version: string
limitation?: { [key: string]: number | boolean }
fees?: { [key: string]: RelayFee[] }
}
export interface RelayInfoObject {
[key: string]: RelayInfo
}
export interface RelayInfoItem {
uri: string
info: RelayInfo
}
export enum RelayConnectionState {
Connected = 'Connected',
NotConnected = 'Failed to connect'
}
export interface RelayConnectionStatus {
[key: string]: RelayConnectionState
}

View File

@ -5,3 +5,4 @@ export * from './misc'
export * from './nostr' export * from './nostr'
export * from './string' export * from './string'
export * from './zip' export * from './zip'
export * from './utils'

View File

@ -85,3 +85,11 @@ export const parseJson = <T>(content: string): Promise<T> => {
} }
}) })
} }
/**
* Capitalizes the first character in the string
* @param str string to modify
* @returns modified string
*/
export const capitalizeFirstLetter = (str: string) =>
str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()

13
src/utils/utils.ts Normal file
View File

@ -0,0 +1,13 @@
export const compareObjects = (
obj1: object | null | undefined,
obj2: object | null | undefined
): boolean => {
if (Array.isArray(obj1) && Array.isArray(obj2)) {
const obj1Copy = [...obj1].sort()
const obj2Copy = [...obj2].sort()
return JSON.stringify(obj1Copy) === JSON.stringify(obj2Copy)
}
return JSON.stringify(obj1) === JSON.stringify(obj2)
}