diff --git a/src/components/AppBar/AppBar.tsx b/src/components/AppBar/AppBar.tsx
index 78f4f01..e4b897c 100644
--- a/src/components/AppBar/AppBar.tsx
+++ b/src/components/AppBar/AppBar.tsx
@@ -17,7 +17,11 @@ 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,
@@ -160,6 +164,16 @@ export const AppBar = () => {
>
Profile
+
{
+ 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()
if (visitedLink) {
diff --git a/src/controllers/NostrController.ts b/src/controllers/NostrController.ts
index 7980f67..4dcf651 100644
--- a/src/controllers/NostrController.ts
+++ b/src/controllers/NostrController.ts
@@ -10,19 +10,22 @@ import {
EventTemplate,
SimplePool,
UnsignedEvent,
+ Filter,
finalizeEvent,
nip04,
- nip19
+ nip19,
+ kinds
} from 'nostr-tools'
import { EventEmitter } from 'tseep'
import { updateNsecbunkerPubkey } from '../store/actions'
import { AuthState, LoginMethods } from '../store/auth/types'
import store from '../store/store'
-import { SignedEvent } from '../types'
+import { SignedEvent, RelayMap } from '../types'
import { getNsecBunkerDelegatedKey, verifySignedEvent } from '../utils'
export class NostrController extends EventEmitter {
private static instance: NostrController
+ private specialMetadataRelay = 'wss://purplepag.es'
private bunkerNDK: NDK | undefined
private remoteSigner: NDKNip46Signer | undefined
@@ -216,12 +219,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 +381,108 @@ 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 = 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 => {
+ 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')}`
+ )
+ }
}
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
new file mode 100644
index 0000000..16c8633
--- /dev/null
+++ b/src/hooks/index.ts
@@ -0,0 +1 @@
+export * from './store'
diff --git a/src/hooks/store.ts b/src/hooks/store.ts
new file mode 100644
index 0000000..f3e9b21
--- /dev/null
+++ b/src/hooks/store.ts
@@ -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()
+export const useAppSelector = useSelector.withTypes()
diff --git a/src/main.tsx b/src/main.tsx
index d586217..135d197 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -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)
)
diff --git a/src/pages/login/index.tsx b/src/pages/login/index.tsx
index 9c224b7..87d89d0 100644
--- a/src/pages/login/index.tsx
+++ b/src/pages/login/index.tsx
@@ -79,7 +79,7 @@ export const Login = () => {
setLoadingSpinnerDesc('Authenticating and finding metadata')
const redirectPath =
- await authController.authenticateAndFindMetadata(pubkey)
+ await authController.authAndGetMetadataAndRelaysMap(pubkey)
navigateAfterLogin(redirectPath)
})
@@ -118,7 +118,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
@@ -213,7 +213,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
@@ -273,7 +273,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
diff --git a/src/pages/relays/index.tsx b/src/pages/relays/index.tsx
new file mode 100644
index 0000000..58dab22
--- /dev/null
+++ b/src/pages/relays/index.tsx
@@ -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()
+ const [newRelayURIerror, setNewRelayURIerror] = useState()
+ const [relayMap, setRelayMap] = useState(
+ 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
+ ) => {
+ 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 (
+
+
+ handleTextFieldChange()}
+ onChange={(e) => setNewRelayURI(e.target.value)}
+ helperText={newRelayURIerror}
+ error={!!newRelayURIerror}
+ placeholder="wss://"
+ className={styles.relayURItextfield}
+ />
+
+
+
+ YOUR RELAYS
+
+ {relayMap && (
+
+ {Object.keys(relayMap).map((relay, i) => (
+
+
+
+
+ handleLeaveRelay(relay)}
+ >
+
+ Leave
+
+
+
+
+
+ handleRelayWriteChange(relay, event)}
+ />
+
+
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/src/pages/relays/style.module.scss b/src/pages/relays/style.module.scss
new file mode 100644
index 0000000..dd9d706
--- /dev/null
+++ b/src/pages/relays/style.module.scss
@@ -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;
+ }
+ }
+}
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
index 1796e19..01c3948 100644
--- a/src/routes/index.tsx
+++ b/src/routes/index.tsx
@@ -6,12 +6,14 @@ import { ProfilePage } from '../pages/profile'
import { hexToNpub } from '../utils'
import { SignPage } from '../pages/sign'
import { VerifyPage } from '../pages/verify'
+import { RelaysPage } from '../pages/relays'
export const appPrivateRoutes = {
homePage: '/',
create: '/create',
sign: '/sign',
- verify: '/verify'
+ verify: '/verify',
+ relays: '/relays'
}
export const appPublicRoutes = {
@@ -57,5 +59,9 @@ export const privateRoutes = [
{
path: appPrivateRoutes.verify,
element:
+ },
+ {
+ path: appPrivateRoutes.relays,
+ element:
}
]
diff --git a/src/store/actionTypes.ts b/src/store/actionTypes.ts
index 6e0cc66..990c495 100644
--- a/src/store/actionTypes.ts
+++ b/src/store/actionTypes.ts
@@ -9,3 +9,5 @@ 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'
diff --git a/src/store/actions.ts b/src/store/actions.ts
index 3bf716b..7512f80 100644
--- a/src/store/actions.ts
+++ b/src/store/actions.ts
@@ -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 {
diff --git a/src/store/relays/action.ts b/src/store/relays/action.ts
new file mode 100644
index 0000000..ff18724
--- /dev/null
+++ b/src/store/relays/action.ts
@@ -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
+})
diff --git a/src/store/relays/reducer.ts b/src/store/relays/reducer.ts
new file mode 100644
index 0000000..5febd1b
--- /dev/null
+++ b/src/store/relays/reducer.ts
@@ -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
diff --git a/src/store/relays/types.ts b/src/store/relays/types.ts
new file mode 100644
index 0000000..3a86aac
--- /dev/null
+++ b/src/store/relays/types.ts
@@ -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
diff --git a/src/store/rootReducer.ts b/src/store/rootReducer.ts
index 03c9b5c..e7f4c33 100644
--- a/src/store/rootReducer.ts
+++ b/src/store/rootReducer.ts
@@ -4,15 +4,19 @@ 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'
export interface State {
auth: AuthState
metadata?: Event
userRobotImage?: string
+ relays: RelaysState
}
export default combineReducers({
auth: authReducer,
metadata: metadataReducer,
- userRobotImage: userRobotImageReducer
+ userRobotImage: userRobotImageReducer,
+ relays: relaysReducer
})
diff --git a/src/store/store.ts b/src/store/store.ts
index 20d9b66..ab20121 100644
--- a/src/store/store.ts
+++ b/src/store/store.ts
@@ -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
diff --git a/src/types/nostr.ts b/src/types/nostr.ts
index 67572d8..d3a0cef 100644
--- a/src/types/nostr.ts
+++ b/src/types/nostr.ts
@@ -17,3 +17,10 @@ export interface NostrJoiningBlock {
block: number
encodedEventPointer: string
}
+
+export type RelayMap = {
+ [key: string]: {
+ read: boolean
+ write: boolean
+ }
+}
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 156c643..d2f5ec1 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -5,3 +5,4 @@ export * from './misc'
export * from './nostr'
export * from './string'
export * from './zip'
+export * from './utils'
diff --git a/src/utils/utils.ts b/src/utils/utils.ts
new file mode 100644
index 0000000..a436fae
--- /dev/null
+++ b/src/utils/utils.ts
@@ -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)
+}