feat(Relays): added logic to manage relays #63
@ -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"
|
||||||
|
@ -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) {
|
||||||
|
@ -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
1
src/hooks/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './store'
|
6
src/hooks/store.ts
Normal file
6
src/hooks/store.ts
Normal 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>()
|
@ -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)
|
||||||
)
|
)
|
||||||
|
@ -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
234
src/pages/relays/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
47
src/pages/relays/style.module.scss
Normal file
47
src/pages/relays/style.module.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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 />
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -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'
|
||||||
|
@ -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 {
|
||||||
|
8
src/store/relays/action.ts
Normal file
8
src/store/relays/action.ts
Normal 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
|
||||||
|
})
|
22
src/store/relays/reducer.ts
Normal file
22
src/store/relays/reducer.ts
Normal 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
12
src/store/relays/types.ts
Normal 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
|
@ -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
|
||||||
})
|
})
|
||||||
|
@ -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>
|
||||||
|
@ -17,3 +17,10 @@ export interface NostrJoiningBlock {
|
|||||||
block: number
|
block: number
|
||||||
encodedEventPointer: string
|
encodedEventPointer: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RelayMap = {
|
||||||
|
[key: string]: {
|
||||||
|
read: boolean
|
||||||
|
write: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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
13
src/utils/utils.ts
Normal 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)
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user
I suggest implementing an indication (green/red dot) if the relay actually works. Because relay can be:
Good suggestion!
I think we should implement this feature later as it feels like nice to have, but is not crucial.