From bfc00114d641bd63466f5fb9dea644e0c273a8ac Mon Sep 17 00:00:00 2001 From: Yury Date: Mon, 20 May 2024 16:21:43 +0300 Subject: [PATCH] feat(Relays): added logic to manage relays --- src/components/AppBar/AppBar.tsx | 16 +- src/controllers/NostrController.ts | 117 ++++++++++++++- src/hooks/index.ts | 1 + src/hooks/store.ts | 6 + src/main.tsx | 3 +- src/pages/relays/index.tsx | 227 +++++++++++++++++++++++++++++ src/pages/relays/style.module.scss | 47 ++++++ src/routes/index.tsx | 8 +- src/store/actionTypes.ts | 2 + src/store/actions.ts | 1 + src/store/relays/action.ts | 8 + src/store/relays/reducer.ts | 22 +++ src/store/relays/types.ts | 12 ++ src/store/rootReducer.ts | 6 +- src/store/store.ts | 5 +- src/types/nostr.ts | 7 + src/utils/index.ts | 1 + src/utils/utils.ts | 13 ++ 18 files changed, 494 insertions(+), 8 deletions(-) create mode 100644 src/hooks/index.ts create mode 100644 src/hooks/store.ts create mode 100644 src/pages/relays/index.tsx create mode 100644 src/pages/relays/style.module.scss create mode 100644 src/store/relays/action.ts create mode 100644 src/store/relays/reducer.ts create mode 100644 src/store/relays/types.ts create mode 100644 src/utils/utils.ts diff --git a/src/components/AppBar/AppBar.tsx b/src/components/AppBar/AppBar.tsx index 40ca9de..207dd01 100644 --- a/src/components/AppBar/AppBar.tsx +++ b/src/components/AppBar/AppBar.tsx @@ -18,7 +18,11 @@ import Username from '../username' import { Link, useNavigate } from 'react-router-dom' import nostrichAvatar from '../../assets/images/avatar.png' import { NostrController } from '../../controllers' -import { appPublicRoutes, getProfileRoute } from '../../routes' +import { + appPrivateRoutes, + appPublicRoutes, + getProfileRoute +} from '../../routes' import { clearAuthToken, saveNsecBunkerDelegatedKey, @@ -143,6 +147,16 @@ export const AppBar = () => { > Profile + { + navigate(appPrivateRoutes.relays) + }} + sx={{ + justifyContent: 'center' + }} + > + Relays + { if (res.status === 'rejected') { failedPublishes.push({ relay: relays[index], - error: res.reason.message + error: res.reason + ? res.reason.message || fallbackRejectionReason + : fallbackRejectionReason }) } }) @@ -364,6 +371,110 @@ export class NostrController extends EventEmitter { return Promise.resolve(pubKey) } + /** + * 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')}` + ) + } + /** * Generates NDK Private Signer * @returns nSecBunker delegated key 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 0b362fc..4da2ffd 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -14,7 +14,8 @@ store.subscribe( _.throttle(() => { saveState({ auth: store.getState().auth, - metadata: store.getState().metadata + metadata: store.getState().metadata, + relays: store.getState().relays }) }, 1000) ) diff --git a/src/pages/relays/index.tsx b/src/pages/relays/index.tsx new file mode 100644 index 0000000..3ee558c --- /dev/null +++ b/src/pages/relays/index.tsx @@ -0,0 +1,227 @@ +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]) + + 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 dbbf728..c764f98 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -5,11 +5,13 @@ import { Login } from '../pages/login' import { ProfilePage } from '../pages/profile' import { hexToNpub } from '../utils' import { VerifyPage } from '../pages/verify' +import { RelaysPage } from '../pages/relays' export const appPrivateRoutes = { homePage: '/', create: '/create', - verify: '/verify' + verify: '/verify', + relays: '/relays' } export const appPublicRoutes = { @@ -51,5 +53,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 d3047a6..524e81f 100644 --- a/src/store/actionTypes.ts +++ b/src/store/actionTypes.ts @@ -7,3 +7,5 @@ export const UPDATE_NSECBUNKER_PUBKEY = 'UPDATE_NSECBUNKER_PUBKEY' export const UPDATE_NSECBUNKER_RELAYS = 'UPDATE_NSECBUNKER_RELAYS' export const SET_METADATA_EVENT = 'SET_METADATA_EVENT' + +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 f56217f..7857e4a 100644 --- a/src/store/rootReducer.ts +++ b/src/store/rootReducer.ts @@ -3,13 +3,17 @@ import { combineReducers } from 'redux' import authReducer from './auth/reducer' import { AuthState } from './auth/types' import metadataReducer from './metadata/reducer' +import { RelaysState } from './relays/types' +import relaysReducer from './relays/reducer' export interface State { auth: AuthState + relays: RelaysState metadata?: Event } export default combineReducers({ auth: authReducer, - metadata: metadataReducer + metadata: metadataReducer, + 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) +}