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 + { + navigate(appPrivateRoutes.relays) + }} + sx={{ + justifyContent: 'center' + }} + > + Relays + { + 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) +}