feat(Relays): added logic to manage relays #63

Closed
y wants to merge 8 commits from relays-management into main
20 changed files with 534 additions and 15 deletions
Showing only changes of commit 338a965f82 - Show all commits

View File

@ -17,7 +17,11 @@ 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, clearState,
@ -160,6 +164,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,27 @@ export class AuthController {
}) })
) )
const relaysState = store.getState().relays
if (relaysState) {
// Relays state is defined and there is no need to await for the latest relay map
this.nostrController.getRelayMap(pubkey).then((relayMap) => {
if (!compareObjects(relaysState?.map, relayMap)) {
store.dispatch(setRelayMapAction(relayMap.map))
}
})
} else {
// Relays state is not defined, await for the latest relay map
const relayMap = await this.nostrController.getRelayMap(pubkey)
if (Object.keys(relayMap).length < 1) {
// Navigate user to relays page
return Promise.resolve(appPrivateRoutes.relays)
}
store.dispatch(setRelayMapAction(relayMap.map))
}
const visitedLink = getVisitedLink() const visitedLink = getVisitedLink()
if (visitedLink) { if (visitedLink) {

View File

@ -10,19 +10,22 @@ import {
EventTemplate, EventTemplate,
SimplePool, SimplePool,
UnsignedEvent, UnsignedEvent,
Filter,
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 } 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 { SignedEvent, RelayMap } from '../types'
import { getNsecBunkerDelegatedKey, verifySignedEvent } from '../utils' import { getNsecBunkerDelegatedKey, verifySignedEvent } from '../utils'
export class NostrController extends EventEmitter { export class NostrController extends EventEmitter {
private static instance: NostrController private static instance: NostrController
private specialMetadataRelay = 'wss://purplepag.es'
private bunkerNDK: NDK | undefined private bunkerNDK: NDK | undefined
private remoteSigner: NDKNip46Signer | undefined private remoteSigner: NDKNip46Signer | undefined
@ -216,12 +219,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 +381,108 @@ 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 = import.meta.env.VITE_MOST_POPULAR_RELAYS
const hardcodedPopularRelays = (mostPopularRelays || '').split(' ')
const popularRelayURIs = [
this.specialMetadataRelay,
...hardcodedPopularRelays
]
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(popularRelayURIs, 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
}
})
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.
* @returns - promise that resolves into a string representing publishing result.
*/
publishRelayMap = async (
relayMap: RelayMap,
npub: 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
// If relay map is empty, use most popular relay URIs
if (!relaysToPublish.length) {
const mostPopularRelays = import.meta.env.VITE_MOST_POPULAR_RELAYS
const hardcodedPopularRelays = (mostPopularRelays || '').split(' ')
relaysToPublish = [this.specialMetadataRelay, ...hardcodedPopularRelays]
}
await this.publishEvent(signedEvent, relaysToPublish)
return Promise.resolve(
`Relay Map published on: ${relaysToPublish.join('\n')}`
)
}
} }

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

@ -79,7 +79,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)
}) })
@ -118,7 +118,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
@ -213,7 +213,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
@ -273,7 +273,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

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

@ -0,0 +1,234 @@
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 } from '../../types'
import LogoutIcon from '@mui/icons-material/Logout'
import { useAppSelector, useAppDispatch } from '../../hooks'
import { compareObjects } from '../../utils'
import { setRelayMapAction } from '../../store/actions'
import { toast } from 'react-toastify'
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
)
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))
}
}
}
}
// 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()
}
// cleanup func
return () => {
isMounted = false
}
}, [dispatch, usersPubkey, relaysState, nostrController])
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) {
if (Object.keys(relayMap).length === 1) {
relayRequirementWarning()
} else {
const relayMapCopy = JSON.parse(JSON.stringify(relayMap))
// Remove relay from relay map
delete relayMapCopy[relay]
setRelayMap(relayMapCopy)
dispatch(setRelayMapAction(relayMapCopy))
if (usersPubkey) {
// Publish updated relay map.
const relayMapPublishingRes = await nostrController
.publishRelayMap(relayMapCopy, usersPubkey)
.catch((err) => handlePublishRelayMapError(err))
if (relayMapPublishingRes) toast.success(relayMapPublishingRes)
}
}
}
}
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
setRelayMap(relayMapCopy)
dispatch(setRelayMapAction(relayMapCopy))
if (usersPubkey) {
// Publish updated relay map
const relayMapPublishingRes = await nostrController
.publishRelayMap(relayMapCopy, usersPubkey)
.catch((err) => handlePublishRelayMapError(err))
if (relayMapPublishingRes) toast.success(relayMapPublishingRes)
}
}
}
}
const handleTextFieldChange = async () => {
// Check if new relay URI is a valid string
if (
newRelayURI &&
!/^wss:\/\/[-a-zA-Z0-9@:%._\\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}/.test(
newRelayURI
)
) {
setNewRelayURIerror(
'New relay URI is not valid. Example of valid relay URI: wss://sigit.relay.io'
)
} else if (newRelayURI) {
const relayMapCopy = JSON.parse(JSON.stringify(relayMap))
relayMapCopy[newRelayURI.trim()] = { write: true, read: true }
setRelayMap(relayMapCopy)
setNewRelayURI('')
dispatch(setRelayMapAction(relayMapCopy))
if (usersPubkey) {
// Publish updated relay map
const relayMapPublishingRes = await nostrController
.publishRelayMap(relayMapCopy, usersPubkey)
.catch((err) => handlePublishRelayMapError(err))
if (relayMapPublishingRes) toast.success(relayMapPublishingRes)
}
}
}
return (
<Box className={styles.container}>
<Box>
<TextField
label="Add new relay"
value={newRelayURI}
onBlur={() => handleTextFieldChange()}
onChange={(e) => setNewRelayURI(e.target.value)}
helperText={newRelayURIerror}
error={!!newRelayURIerror}
placeholder="wss://"
className={styles.relayURItextfield}
/>
</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>
Review

I suggest implementing an indication (green/red dot) if the relay actually works. Because relay can be:

  • Unreachable (down)
  • Requiring payment

image example

I suggest implementing an indication (green/red dot) if the relay actually works. Because relay can be: - Unreachable (down) - Requiring payment ![image example](https://paste.4gl.io/?b27a689748f5d0e4#FPrJLQbXDrKv3CAZ7iaYEma4cKgTNGihXEpWprFdWvhw)
Review

Good suggestion!
I think we should implement this feature later as it feels like nice to have, but is not crucial.

Good suggestion! I think we should implement this feature later as it feels like nice to have, but is not crucial.
<ListItem>
<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?" />
<Switch
checked={relayMap[relay].write}
onChange={(event) => handleRelayWriteChange(relay, event)}
/>
</ListItem>
</List>
</Box>
))}
</Box>
)}
</Box>
)
}

View File

@ -0,0 +1,47 @@
@import '../../colors.scss';
.container {
margin-top: 25px;
.relayURItextfield {
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;
}
}
}

View File

@ -6,12 +6,14 @@ 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 { RelaysPage } from '../pages/relays'
export const appPrivateRoutes = { export const appPrivateRoutes = {
homePage: '/', homePage: '/',
create: '/create', create: '/create',
sign: '/sign', sign: '/sign',
verify: '/verify' verify: '/verify',
relays: '/relays'
} }
export const appPublicRoutes = { export const appPublicRoutes = {
@ -57,5 +59,9 @@ export const privateRoutes = [
{ {
path: appPrivateRoutes.verify, path: appPrivateRoutes.verify,
element: <VerifyPage /> element: <VerifyPage />
},
{
path: appPrivateRoutes.relays,
element: <RelaysPage />
} }
] ]

View File

@ -9,3 +9,5 @@ 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'

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 {

View File

@ -0,0 +1,8 @@
import * as ActionTypes from '../actionTypes'
import { SetRelayMapAction } from './types'
import { RelayMap } from '../../types'
export const setRelayMapAction = (payload: RelayMap): SetRelayMapAction => ({
type: ActionTypes.SET_RELAY_MAP,
payload
})

View File

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

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

@ -0,0 +1,12 @@
import * as ActionTypes from '../actionTypes'
import { RestoreState } from '../actions'
import { RelayMap } from '../../types'
export type RelaysState = { map: RelayMap; mapUpdated: number }
export interface SetRelayMapAction {
type: typeof ActionTypes.SET_RELAY_MAP
payload: RelayMap
}
export type RelaysDispatchTypes = SetRelayMapAction | RestoreState

View File

@ -4,15 +4,19 @@ 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'
export interface State { export interface State {
auth: AuthState auth: AuthState
metadata?: Event metadata?: Event
userRobotImage?: string userRobotImage?: string
relays: RelaysState
} }
export default combineReducers({ export default combineReducers({
auth: authReducer, auth: authReducer,
metadata: metadataReducer, metadata: metadataReducer,
userRobotImage: userRobotImageReducer userRobotImage: userRobotImageReducer,
relays: relaysReducer
}) })

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

@ -17,3 +17,10 @@ export interface NostrJoiningBlock {
block: number block: number
encodedEventPointer: string encodedEventPointer: string
} }
export type RelayMap = {
[key: string]: {
read: boolean
write: boolean
}
}

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'

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)
}