relay-management-latest #75

Merged
y merged 9 commits from relay-management-latest into main 2024-05-24 13:48:01 +00:00
20 changed files with 532 additions and 16 deletions
Showing only changes of commit 64f822743f - 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

@ -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

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

@ -7,13 +7,15 @@ 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 { 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 = {
@ -66,5 +68,6 @@ export const privateRoutes = [
{ {
path: appPrivateRoutes.profileSettings, path: appPrivateRoutes.profileSettings,
element: <ProfileSettingsPage /> element: <ProfileSettingsPage />
} },
{ 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,
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
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)
}