staging #87

Merged
y merged 11 commits from staging into main 2024-05-29 09:26:04 +00:00
24 changed files with 1527 additions and 49 deletions
Showing only changes of commit 289b85a549 - Show all commits

View File

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

View File

@ -1,13 +1,18 @@
import { EventTemplate } from 'nostr-tools'
import { MetadataController, NostrController } from '.'
import { setAuthState, setMetadataEvent } from '../store/actions'
import {
setAuthState,
setMetadataEvent,
setRelayMapAction
} from '../store/actions'
import store from '../store/store'
import {
base64DecodeAuthToken,
base64EncodeSignedEvent,
getAuthToken,
getVisitedLink,
saveAuthToken
saveAuthToken,
compareObjects
} from '../utils'
import { appPrivateRoutes } from '../routes'
import { SignedEvent } from '../types'
@ -30,7 +35,7 @@ export class AuthController {
* @returns url to redirect if authentication successfull
* or error if otherwise
*/
async authenticateAndFindMetadata(pubkey: string) {
async authAndGetMetadataAndRelaysMap(pubkey: string) {
const emptyMetadata = this.metadataController.getEmptyMetadataEvent()
this.metadataController
@ -69,15 +74,32 @@ export class AuthController {
})
)
const visitedLink = getVisitedLink()
const relayMap = await this.nostrController.getRelayMap(pubkey)
if (visitedLink) {
const { pathname, search } = visitedLink
if (Object.keys(relayMap).length < 1) {
// Navigate user to relays page if relay map is empty
return Promise.resolve(appPrivateRoutes.relays)
}
return Promise.resolve(`${pathname}${search}`)
} else {
// Navigate user in
return Promise.resolve(appPrivateRoutes.homePage)
if (store.getState().auth?.loggedIn) {
if (!compareObjects(store.getState().relays?.map, relayMap.map))
store.dispatch(setRelayMapAction(relayMap.map))
}
const currentLocation = window.location.hash.replace('#', '')
if (!Object.values(appPrivateRoutes).includes(currentLocation)) {
// User did change the location to one of the private routes
const visitedLink = getVisitedLink()
if (visitedLink) {
const { pathname, search } = visitedLink
return Promise.resolve(`${pathname}${search}`)
} else {
// Navigate user in
return Promise.resolve(appPrivateRoutes.homePage)
}
}
}

View File

@ -58,14 +58,15 @@ export class MetadataController {
return metadataEvent
}
const mostPopularRelays = import.meta.env.VITE_MOST_POPULAR_RELAYS
const hardcodedPopularRelays = (mostPopularRelays || '').split(' ')
const relays = [...hardcodedPopularRelays]
const mostPopularRelays = await this.nostrController.getMostPopularRelays()
const events = await pool.querySync(relays, eventFilter).catch((err) => {
console.error(err)
return null
})
const events = await pool
.querySync(mostPopularRelays, eventFilter)
.catch((err) => {
console.error(err)
return null
})
if (events && events.length) {
events.sort((a, b) => b.created_at - a.created_at)
@ -96,14 +97,15 @@ export class MetadataController {
})
if (!relayEvent) {
const mostPopularRelays = import.meta.env.VITE_MOST_POPULAR_RELAYS
const hardcodedPopularRelays = (mostPopularRelays || '').split(' ')
const relays = [...hardcodedPopularRelays]
const mostPopularRelays =
await this.nostrController.getMostPopularRelays()
relayEvent = await pool.get(relays, eventFilter).catch((err) => {
console.error(err)
return null
})
relayEvent = await pool
.get(mostPopularRelays, eventFilter)
.catch((err) => {
console.error(err)
return null
})
}
if (relayEvent) {

View File

@ -3,23 +3,45 @@ import NDK, {
NDKNip46Signer,
NDKPrivateKeySigner,
NDKUser,
NostrEvent
NostrEvent,
NDKSubscription
} from '@nostr-dev-kit/ndk'
import {
Event,
EventTemplate,
SimplePool,
UnsignedEvent,
Filter,
Relay,
finalizeEvent,
nip04,
nip19
nip19,
kinds
} from 'nostr-tools'
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 store from '../store/store'
import { SignedEvent } from '../types'
import { getNsecBunkerDelegatedKey, verifySignedEvent } from '../utils'
import {
SignedEvent,
RelayMap,
RelayStats,
ReadRelay,
RelayInfoObject,
RelayConnectionStatus,
RelayConnectionState
} from '../types'
import {
compareObjects,
getNsecBunkerDelegatedKey,
verifySignedEvent
} from '../utils'
import axios from 'axios'
export class NostrController extends EventEmitter {
private static instance: NostrController
@ -27,6 +49,8 @@ export class NostrController extends EventEmitter {
private bunkerNDK: NDK | undefined
private remoteSigner: NDKNip46Signer | undefined
private connectedRelays: Relay[] | undefined
private constructor() {
super()
}
@ -216,12 +240,16 @@ export class NostrController extends EventEmitter {
if (publishedRelays.length === 0) {
const failedPublishes: any[] = []
const fallbackRejectionReason =
'Attempt to publish an event has been rejected with unknown reason.'
results.forEach((res, index) => {
if (res.status === 'rejected') {
failedPublishes.push({
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 => {
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({
auth: store.getState().auth,
metadata: store.getState().metadata,
userRobotImage: store.getState().userRobotImage
userRobotImage: store.getState().userRobotImage,
relays: store.getState().relays
})
}, 1000)
)

View File

@ -81,9 +81,9 @@ export const Login = () => {
setLoadingSpinnerDesc('Authenticating and finding metadata')
const redirectPath =
await authController.authenticateAndFindMetadata(pubkey)
await authController.authAndGetMetadataAndRelaysMap(pubkey)
navigateAfterLogin(redirectPath)
if (redirectPath) navigateAfterLogin(redirectPath)
})
.catch((err) => {
toast.error('Error capturing public key from nostr extension: ' + err)
@ -135,7 +135,7 @@ export const Login = () => {
setLoadingSpinnerDesc('Authenticating and finding metadata')
const redirectPath = await authController
.authenticateAndFindMetadata(publickey)
.authAndGetMetadataAndRelaysMap(publickey)
.catch((err) => {
toast.error('Error occurred in authentication: ' + err)
return null
@ -230,7 +230,7 @@ export const Login = () => {
setLoadingSpinnerDesc('Authenticating and finding metadata')
const redirectPath = await authController
.authenticateAndFindMetadata(pubkey!)
.authAndGetMetadataAndRelaysMap(pubkey!)
.catch((err) => {
toast.error('Error occurred in authentication: ' + err)
return null
@ -290,7 +290,7 @@ export const Login = () => {
setLoadingSpinnerDesc('Authenticating and finding metadata')
const redirectPath = await authController
.authenticateAndFindMetadata(pubkey!)
.authAndGetMetadataAndRelaysMap(pubkey!)
.catch((err) => {
toast.error('Error occurred in authentication: ' + err)
return null

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

@ -0,0 +1,518 @@
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(() => {
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 > 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,107 @@
@import '../../colors.scss';
.container {
margin-top: 25px;
color: $text-color;
.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

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

View File

@ -1,5 +1,7 @@
export const RESTORE_STATE = 'RESTORE_STATE'
export const USER_LOGOUT = 'USER_LOGOUT'
export const SET_AUTH_STATE = 'SET_AUTH_STATE'
export const UPDATE_LOGIN_METHOD = 'UPDATE_LOGIN_METHOD'
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_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 './metadata/action'
export * from './relays/action'
export const restoreState = (payload: State) => {
return {
@ -15,3 +16,9 @@ export interface RestoreState {
type: typeof ActionTypes.RESTORE_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 metadataReducer from './metadata/reducer'
import userRobotImageReducer from './userRobotImage/reducer'
import { RelaysState } from './relays/types'
import relaysReducer from './relays/reducer'
import * as ActionTypes from './actionTypes'
export interface State {
auth: AuthState
metadata?: Event
userRobotImage?: string
relays: RelaysState
}
export default combineReducers({
export const appReducer = combineReducers({
auth: authReducer,
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 rootReducer from './rootReducer'
const store = configureStore({ reducer: rootReducer })
const store = configureStore({
reducer: rootReducer
})
export default store
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 './profile'
export * from './zip'
export * from './relay'

View File

@ -8,11 +8,6 @@ export interface SignedEvent {
sig: string
}
export interface RelaySet {
read: string[]
write: string[]
}
export interface NostrJoiningBlock {
block: number
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 './string'
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)
}