From 338a965f8279e7a7ba88b47792835af4d8a2016c Mon Sep 17 00:00:00 2001 From: Yury Date: Tue, 21 May 2024 08:02:48 +0300 Subject: [PATCH 1/8] feat(Relays): added logic to manage relays --- src/components/AppBar/AppBar.tsx | 16 +- src/controllers/AuthController.ts | 32 +++- src/controllers/NostrController.ts | 117 ++++++++++++++- src/hooks/index.ts | 1 + src/hooks/store.ts | 6 + src/main.tsx | 3 +- src/pages/login/index.tsx | 8 +- src/pages/relays/index.tsx | 234 +++++++++++++++++++++++++++++ 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 ++ 20 files changed, 534 insertions(+), 15 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 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) +} -- 2.34.1 From e3db8950862e07bb39db0a64d3495880b47143b2 Mon Sep 17 00:00:00 2001 From: Yury Date: Fri, 24 May 2024 15:29:00 +0300 Subject: [PATCH 2/8] feat(Store): configured relays state --- src/store/actionTypes.ts | 6 ++++++ src/store/relays/action.ts | 35 +++++++++++++++++++++++++++++++++-- src/store/relays/reducer.ts | 28 ++++++++++++++++++++++++++-- src/store/relays/types.ts | 37 ++++++++++++++++++++++++++++++++++--- 4 files changed, 99 insertions(+), 7 deletions(-) diff --git a/src/store/actionTypes.ts b/src/store/actionTypes.ts index 990c495..18b4063 100644 --- a/src/store/actionTypes.ts +++ b/src/store/actionTypes.ts @@ -1,5 +1,7 @@ export const RESTORE_STATE = 'RESTORE_STATE' +export const USER_LOGOUT = 'USER_LOGOUT' + export const SET_AUTH_STATE = 'SET_AUTH_STATE' export const UPDATE_LOGIN_METHOD = 'UPDATE_LOGIN_METHOD' export const UPDATE_KEYPAIR = 'UPDATE_KEYPAIR' @@ -11,3 +13,7 @@ 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' +export const SET_RELAY_INFO = 'SET_RELAY_INFO' +export const SET_RELAY_MAP_UPDATED = 'SET_RELAY_MAP_UPDATED' +export const SET_MOST_POPULAR_RELAYS = 'SET_MOST_POPULAR_RELAYS' +export const SET_RELAY_CONNECTION_STATUS = 'SET_RELAY_CONNECTION_STATUS' diff --git a/src/store/relays/action.ts b/src/store/relays/action.ts index ff18724..6f95840 100644 --- a/src/store/relays/action.ts +++ b/src/store/relays/action.ts @@ -1,8 +1,39 @@ import * as ActionTypes from '../actionTypes' -import { SetRelayMapAction } from './types' -import { RelayMap } from '../../types' +import { + SetRelayMapAction, + SetMostPopularRelaysAction, + SetRelayInfoAction, + SetRelayConnectionStatusAction, + SetRelayMapUpdatedAction +} from './types' +import { RelayMap, RelayInfoObject, RelayConnectionStatus } from '../../types' export const setRelayMapAction = (payload: RelayMap): SetRelayMapAction => ({ type: ActionTypes.SET_RELAY_MAP, payload }) + +export const setRelayInfoAction = ( + payload: RelayInfoObject +): SetRelayInfoAction => ({ + type: ActionTypes.SET_RELAY_INFO, + payload +}) + +export const setMostPopularRelaysAction = ( + payload: string[] +): SetMostPopularRelaysAction => ({ + type: ActionTypes.SET_MOST_POPULAR_RELAYS, + payload +}) + +export const setRelayConnectionStatusAction = ( + payload: RelayConnectionStatus +): SetRelayConnectionStatusAction => ({ + type: ActionTypes.SET_RELAY_CONNECTION_STATUS, + payload +}) + +export const setRelayMapUpdatedAction = (): SetRelayMapUpdatedAction => ({ + type: ActionTypes.SET_RELAY_MAP_UPDATED +}) diff --git a/src/store/relays/reducer.ts b/src/store/relays/reducer.ts index 5febd1b..b4b9854 100644 --- a/src/store/relays/reducer.ts +++ b/src/store/relays/reducer.ts @@ -1,7 +1,13 @@ import * as ActionTypes from '../actionTypes' import { RelaysDispatchTypes, RelaysState } from './types' -const initialState: RelaysState | null = null +const initialState: RelaysState = { + map: undefined, + mapUpdated: undefined, + mostPopular: undefined, + info: undefined, + connectionStatus: undefined +} const reducer = ( state = initialState, @@ -9,7 +15,25 @@ const reducer = ( ): RelaysState | null => { switch (action.type) { case ActionTypes.SET_RELAY_MAP: - return { map: action.payload, mapUpdated: Date.now() } + return { ...state, map: action.payload, mapUpdated: Date.now() } + + case ActionTypes.SET_RELAY_MAP_UPDATED: + return { ...state, mapUpdated: Date.now() } + + case ActionTypes.SET_RELAY_INFO: + return { + ...state, + info: { ...state.info, ...action.payload } + } + + case ActionTypes.SET_RELAY_CONNECTION_STATUS: + return { + ...state, + connectionStatus: action.payload + } + + case ActionTypes.SET_MOST_POPULAR_RELAYS: + return { ...state, mostPopular: action.payload } case ActionTypes.RESTORE_STATE: return action.payload.relays diff --git a/src/store/relays/types.ts b/src/store/relays/types.ts index 3a86aac..e1c4da8 100644 --- a/src/store/relays/types.ts +++ b/src/store/relays/types.ts @@ -1,12 +1,43 @@ import * as ActionTypes from '../actionTypes' import { RestoreState } from '../actions' -import { RelayMap } from '../../types' +import { RelayMap, RelayInfoObject, RelayConnectionStatus } from '../../types' -export type RelaysState = { map: RelayMap; mapUpdated: number } +export type RelaysState = { + map?: RelayMap + mapUpdated?: number + mostPopular?: string[] + info?: RelayInfoObject + connectionStatus?: RelayConnectionStatus +} export interface SetRelayMapAction { type: typeof ActionTypes.SET_RELAY_MAP payload: RelayMap } -export type RelaysDispatchTypes = SetRelayMapAction | RestoreState +export interface SetMostPopularRelaysAction { + type: typeof ActionTypes.SET_MOST_POPULAR_RELAYS + payload: string[] +} + +export interface SetRelayInfoAction { + type: typeof ActionTypes.SET_RELAY_INFO + payload: RelayInfoObject +} + +export interface SetRelayConnectionStatusAction { + type: typeof ActionTypes.SET_RELAY_CONNECTION_STATUS + payload: RelayConnectionStatus +} + +export interface SetRelayMapUpdatedAction { + type: typeof ActionTypes.SET_RELAY_MAP_UPDATED +} + +export type RelaysDispatchTypes = + | SetRelayMapAction + | SetRelayInfoAction + | SetRelayMapUpdatedAction + | SetMostPopularRelaysAction + | SetRelayConnectionStatusAction + | RestoreState -- 2.34.1 From 424e06a52bf9d7a2d317b7f9e8ae629f4e29f304 Mon Sep 17 00:00:00 2001 From: Yury Date: Fri, 24 May 2024 15:29:45 +0300 Subject: [PATCH 3/8] chore(Types): added relay types --- src/types/index.ts | 1 + src/types/nostr.ts | 12 --- src/types/relay.ts | 232 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 233 insertions(+), 12 deletions(-) create mode 100644 src/types/relay.ts diff --git a/src/types/index.ts b/src/types/index.ts index ef2283f..9397745 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -2,3 +2,4 @@ export * from './core' export * from './nostr' export * from './profile' export * from './zip' +export * from './relay' diff --git a/src/types/nostr.ts b/src/types/nostr.ts index d3a0cef..a7d6f0c 100644 --- a/src/types/nostr.ts +++ b/src/types/nostr.ts @@ -8,19 +8,7 @@ export interface SignedEvent { sig: string } -export interface RelaySet { - read: string[] - write: string[] -} - export interface NostrJoiningBlock { block: number encodedEventPointer: string } - -export type RelayMap = { - [key: string]: { - read: boolean - write: boolean - } -} diff --git a/src/types/relay.ts b/src/types/relay.ts new file mode 100644 index 0000000..ee426f2 --- /dev/null +++ b/src/types/relay.ts @@ -0,0 +1,232 @@ +export interface RelaySet { + read: string[] + write: string[] +} + +export type RelayMap = { + [key: string]: { + read: boolean + write: boolean + } +} + +export interface RelayStats { + relays: number + pubKeys: number + users: number + trusted_users: number + events: number + posts: number + zaps: number + zap_amount: number + daily: Daily + daily_totals: DailyTotals + relay_stats: RelayStats +} + +export interface RelayStats { + user_picks: UserPicks + written: Written +} + +export interface Written { + last_week: LastWeek[] +} + +export interface LastWeek { + d: string + p: number + ps: number + e: number + es: number +} + +export interface UserPicks { + read_relays: ReadRelay[] + write_relays: ReadRelay[] +} + +export interface ReadRelay { + d: string + r: number + w: number + rs: number + ws: number +} + +export interface DailyTotals { + datasets: Datasets2 +} + +export interface Datasets2 { + kind_0: Kind0[] + kind_1: Kind0[] + kind_2: Kind0[] + kind_3: Kind0[] + kind_5: Kind0[] + kind_6: Kind0[] + kind_7: Kind0[] + kind_1984: Kind0[] + kind_9735: Kind0[] + kind_1063: Kind0[] + kind_6969: Kind0[] + kind_9802: Kind0[] + kind_30000: Kind0[] + kind_30001: Kind0[] + kind_30008: Kind0[] + kind_30009: Kind0[] + kind_30017: Kind0[] + kind_30018: Kind0[] + kind_30023: Kind0[] + kind_31337: Kind0[] + totals: Kind0[] + new_profiles: Kind0[] + new_pubkeys: Kind0[] + new_contact_lists: Kind0[] + new_ln: Kind0[] + new_users: Kind0[] + total_zap_amount: Kind0[] + zappers: Kind0[] + zapped_pubkeys: Kind0[] + zapped_events: Kind0[] + zap_providers: Kind0[] +} + +export interface Daily { + datasets: Datasets +} + +export interface Datasets { + kind_0: Kind0[] + kind_1: Kind0[] + kind_2: Kind0[] + kind_3: Kind0[] + kind_5: Kind0[] + kind_6: Kind0[] + kind_7: Kind0[] + kind_1984: Kind0[] + kind_9735: Kind0[] + kind_1063: Kind0[] + kind_6969: Kind0[] + kind_9802: Kind0[] + kind_30000: Kind0[] + kind_30001: Kind0[] + kind_30008: Kind0[] + kind_30009: Kind0[] + kind_30017: Kind0[] + kind_30018: Kind0[] + kind_30023: Kind0[] + kind_31337: Kind0[] + totals: Kind0[] + new_profiles: Kind0[] + new_pubkeys: Kind0[] + new_contact_lists: Kind0[] + new_ln: Kind0[] + new_users: Kind0[] + max_zap_amount: Kind0[] + avg_zap_amount: Kind0[] + total_zap_amount: Kind0[] + active_pubkeys: Kind0[] + active_pubkeys_total: Kind0[] + active_pubkeys_week: Kind0[] + active_pubkeys_total_week: Kind0[] + active_relays: Kind0[] + zappers: Kind0[] + zapped_pubkeys: Kind0[] + zapped_events: Kind0[] + zap_providers: Kind0[] + retention: Retention +} + +export interface Retention { + all: All[] + tr: All[] + bio: All[] + all_curves: Allcurve[] + tr_curves: Allcurve[] + bio_curves: Allcurve[] +} + +export interface Allcurve { + day: number + '2023-02': number + '2023-03': number + '2023-04': number + '2023-05': number + '2023-06': number + '2023-07': number +} + +export interface All { + d: string + signups: number + retained: number + retd_posts: number + retd_replies: number + retd_reposts: number + retd_likes: number + retd_liked: number + retd_liked_pubkeys: number + retd_replied: number + retd_replied_pubkeys: number + retd_zaps_received: number + retd_zaps_received_msats: number + retd_zaps_sent: number + retd_zaps_sent_msats: number + retd_following: number + retd_followers: number + lost_posts: number + lost_replies: number + lost_reposts: number + lost_likes: number + lost_liked: number + lost_liked_pubkeys: number + lost_replied: number + lost_replied_pubkeys: number + lost_zaps_received: number + lost_zaps_received_msats: number + lost_zaps_sent: number + lost_zaps_sent_msats: number + lost_following: number + lost_followers: number +} + +export interface Kind0 { + d: string + c: number +} + +export interface RelayFee { + amount: number + unit: string +} + +export interface RelayInfo { + name: string + description: string + pubkey: string + contact: string + supported_nips: number[] + software: string + version: string + limitation?: { [key: string]: number | boolean } + fees?: { [key: string]: RelayFee[] } +} + +export interface RelayInfoObject { + [key: string]: RelayInfo +} + +export interface RelayInfoItem { + uri: string + info: RelayInfo +} + +export enum RelayConnectionState { + Connected = 'Connected', + NotConnected = 'Failed to connect' +} + +export interface RelayConnectionStatus { + [key: string]: RelayConnectionState +} -- 2.34.1 From f4ecbe5f390de0a05e9b6d9115225fd6d849151a Mon Sep 17 00:00:00 2001 From: Yury Date: Fri, 24 May 2024 15:30:29 +0300 Subject: [PATCH 4/8] chore(Store): added USER_LOGOUT action --- src/store/actions.ts | 6 ++++++ src/store/rootReducer.ts | 14 +++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/store/actions.ts b/src/store/actions.ts index 7512f80..a101199 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -16,3 +16,9 @@ export interface RestoreState { type: typeof ActionTypes.RESTORE_STATE payload: State } + +export const userLogOutAction = () => { + return { + type: ActionTypes.USER_LOGOUT + } +} diff --git a/src/store/rootReducer.ts b/src/store/rootReducer.ts index e7f4c33..517291c 100644 --- a/src/store/rootReducer.ts +++ b/src/store/rootReducer.ts @@ -6,6 +6,7 @@ import metadataReducer from './metadata/reducer' import userRobotImageReducer from './userRobotImage/reducer' import { RelaysState } from './relays/types' import relaysReducer from './relays/reducer' +import * as ActionTypes from './actionTypes' export interface State { auth: AuthState @@ -14,9 +15,20 @@ export interface State { relays: RelaysState } -export default combineReducers({ +export const appReducer = combineReducers({ auth: authReducer, metadata: metadataReducer, userRobotImage: userRobotImageReducer, relays: relaysReducer }) + +// FIXME: define types +export default (state: any, action: any) => { + switch (action.type) { + case ActionTypes.USER_LOGOUT: + return appReducer(undefined, action) + + default: + return appReducer(state, action) + } +} -- 2.34.1 From d3d87be2c30c370f98bae8f1e4c54aac8ded8137 Mon Sep 17 00:00:00 2001 From: Yury Date: Fri, 24 May 2024 15:31:53 +0300 Subject: [PATCH 5/8] fix(LogOut): used log out action instead of clearState utility --- src/components/AppBar/AppBar.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/components/AppBar/AppBar.tsx b/src/components/AppBar/AppBar.tsx index e4b897c..b392d9a 100644 --- a/src/components/AppBar/AppBar.tsx +++ b/src/components/AppBar/AppBar.tsx @@ -10,7 +10,11 @@ import { import { useEffect, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' -import { setAuthState, setMetadataEvent } from '../../store/actions' +import { + setAuthState, + setMetadataEvent, + userLogOutAction +} from '../../store/actions' import { State } from '../../store/rootReducer' import { Dispatch } from '../../store/store' import Username from '../username' @@ -24,7 +28,6 @@ import { } from '../../routes' import { clearAuthToken, - clearState, saveNsecBunkerDelegatedKey, shorten } from '../../utils' @@ -96,7 +99,8 @@ export const AppBar = () => { // clear authToken saved in local storage clearAuthToken() - clearState() + + dispatch(userLogOutAction()) // update nsecBunker delegated key after logout const nostrController = NostrController.getInstance() -- 2.34.1 From 8790a943c3f424171325df4dcc7853f90003903d Mon Sep 17 00:00:00 2001 From: Yury Date: Fri, 24 May 2024 15:39:37 +0300 Subject: [PATCH 6/8] feat(Relays): improved relays page --- src/pages/relays/index.tsx | 350 ++++++++++++++++++++++++++--- src/pages/relays/style.module.scss | 59 +++++ src/utils/string.ts | 8 + 3 files changed, 381 insertions(+), 36 deletions(-) diff --git a/src/pages/relays/index.tsx b/src/pages/relays/index.tsx index 58dab22..010d24d 100644 --- a/src/pages/relays/index.tsx +++ b/src/pages/relays/index.tsx @@ -6,12 +6,32 @@ 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 { + RelayMap, + RelayInfoObject, + RelayFee, + RelayConnectionState +} from '../../types' import LogoutIcon from '@mui/icons-material/Logout' import { useAppSelector, useAppDispatch } from '../../hooks' -import { compareObjects } from '../../utils' -import { setRelayMapAction } from '../../store/actions' +import { + compareObjects, + shorten, + hexToNpub, + capitalizeFirstLetter +} from '../../utils' +import { + setRelayMapAction, + setRelayMapUpdatedAction +} from '../../store/actions' import { toast } from 'react-toastify' +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown' +import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp' +import ContentCopyIcon from '@mui/icons-material/ContentCopy' +import ElectricBoltIcon from '@mui/icons-material/ElectricBolt' +import { Tooltip } from '@mui/material' +import InputAdornment from '@mui/material/InputAdornment' +import Button from '@mui/material/Button' export const RelaysPage = () => { const nostrController = NostrController.getInstance() @@ -26,6 +46,30 @@ export const RelaysPage = () => { const [relayMap, setRelayMap] = useState( relaysState?.map ) + const [relaysInfo, setRelaysInfo] = useState( + relaysState?.info + ) + const [displayRelaysInfo, setDisplayRelaysInfo] = useState([]) + const [relaysConnectionStatus, setRelaysConnectionStatus] = useState( + relaysState?.connectionStatus + ) + + const webSocketPrefix = 'wss://' + + // Update relay connection status + useEffect(() => { + if ( + !compareObjects(relaysConnectionStatus, relaysState?.connectionStatus) + ) { + setRelaysConnectionStatus(relaysState?.connectionStatus) + } + }, [relaysConnectionStatus, relaysState?.connectionStatus]) + + useEffect(() => { + if (!compareObjects(relaysInfo, relaysState?.info)) { + setRelaysInfo(relaysState?.info) + } + }, [relaysInfo, relaysState?.info]) useEffect(() => { let isMounted = false @@ -42,7 +86,7 @@ export const RelaysPage = () => { if ( !relaysState?.mapUpdated || newRelayMap.mapUpdated > relaysState?.mapUpdated - ) + ) { if ( !relaysState?.map || !compareObjects(relaysState.map, newRelayMap) @@ -50,7 +94,11 @@ export const RelaysPage = () => { setRelayMap(newRelayMap.map) dispatch(setRelayMapAction(newRelayMap.map)) + } else { + // Update relay map updated timestamp + dispatch(setRelayMapUpdatedAction()) } + } } } } @@ -63,13 +111,32 @@ export const RelaysPage = () => { Date.now() - relaysState?.mapUpdated > 5 * 60 * 1000) // 5 minutes ) { fetchData() + + // Update relay connection status + if (relaysConnectionStatus) { + const notConnectedRelays = Object.keys(relaysConnectionStatus).filter( + (key) => + relaysConnectionStatus[key] === RelayConnectionState.NotConnected + ) + + if (notConnectedRelays.length) { + nostrController.connectToRelays(notConnectedRelays) + } + } } // cleanup func return () => { isMounted = false } - }, [dispatch, usersPubkey, relaysState, nostrController]) + }, [ + dispatch, + usersPubkey, + relaysState?.map, + relaysState?.mapUpdated, + nostrController, + relaysConnectionStatus + ]) useEffect(() => { // Display notification if an empty relay map has been received @@ -83,25 +150,38 @@ export const RelaysPage = () => { const handleLeaveRelay = async (relay: string) => { if (relayMap) { - if (Object.keys(relayMap).length === 1) { + const relaysInMap = Object.keys(relayMap).length + const writeRelays = Object.keys(relayMap).filter( + (key) => relayMap[key].write + ) + + // Check if at least one write relay is present in relay map + if ( + relaysInMap <= 1 || + (writeRelays.length === 1 && writeRelays.includes(relay)) + ) { 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) + .publishRelayMap(relayMapCopy, usersPubkey, [relay]) .catch((err) => handlePublishRelayMapError(err)) - if (relayMapPublishingRes) toast.success(relayMapPublishingRes) + if (relayMapPublishingRes) { + toast.success(relayMapPublishingRes) + + setRelayMap(relayMapCopy) + + dispatch(setRelayMapAction(relayMapCopy)) + } } + + nostrController.disconnectFromRelays([relay]) } } } @@ -135,67 +215,120 @@ export const RelaysPage = () => { 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) + if (relayMapPublishingRes) { + toast.success(relayMapPublishingRes) + + setRelayMap(relayMapCopy) + + dispatch(setRelayMapAction(relayMapCopy)) + } } } } } - const handleTextFieldChange = async () => { + const handleAddNewRelay = async () => { + const relayURI = `${webSocketPrefix}${newRelayURI?.trim().replace(webSocketPrefix, '')}` + // Check if new relay URI is a valid string if ( - newRelayURI && + relayURI && !/^wss:\/\/[-a-zA-Z0-9@:%._\\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}/.test( - newRelayURI + relayURI ) ) { - 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)) + if (relayURI !== webSocketPrefix) { + setNewRelayURIerror( + 'New relay URI is not valid. Example of valid relay URI: wss://sigit.relay.io' + ) + } + } else if (relayURI && usersPubkey) { + const connectionStatus = await nostrController.connectToRelays([relayURI]) - relayMapCopy[newRelayURI.trim()] = { write: true, read: true } + if ( + connectionStatus && + connectionStatus[relayURI] && + connectionStatus[relayURI] === RelayConnectionState.Connected + ) { + const relayMapCopy = JSON.parse(JSON.stringify(relayMap)) - setRelayMap(relayMapCopy) - setNewRelayURI('') + relayMapCopy[relayURI] = { write: true, read: true } - 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) + if (relayMapPublishingRes) { + setRelayMap(relayMapCopy) + setNewRelayURI('') + + dispatch(setRelayMapAction(relayMapCopy)) + + nostrController.getRelayInfo([relayURI]) + + toast.success(relayMapPublishingRes) + } + + setNewRelayURIerror(undefined) + } else { + toast.error(`Relay '${relayURI}' wasn't added.`) + + setNewRelayURIerror(`Connection to '${relayURI}' was unsuccessful.`) + } + } + } + + // Handle relay open and close state + const handleRelayInfo = (relay: string) => { + if (relaysInfo) { + const info = relaysInfo[relay] + + if (info) { + let displayRelaysInfoCopy: string[] = JSON.parse( + JSON.stringify(displayRelaysInfo) + ) + + if (displayRelaysInfoCopy.includes(relay)) { + displayRelaysInfoCopy = displayRelaysInfoCopy.filter( + (rel) => rel !== relay + ) + } else { + displayRelaysInfoCopy.push(relay) + } + + setDisplayRelaysInfo(displayRelaysInfoCopy) } } } return ( - + handleTextFieldChange()} onChange={(e) => setNewRelayURI(e.target.value)} helperText={newRelayURIerror} error={!!newRelayURIerror} - placeholder="wss://" + InputProps={{ + startAdornment: ( + + {webSocketPrefix} + + ) + }} className={styles.relayURItextfield} /> + @@ -207,7 +340,32 @@ export const RelaysPage = () => { + + {relaysInfo && + relaysInfo[relay] && + relaysInfo[relay].limitation && + relaysInfo[relay].limitation?.payment_required && ( + + handleRelayInfo(relay)} + /> + + )} + + handleLeaveRelay(relay)} @@ -218,12 +376,132 @@ export const RelaysPage = () => { - + handleRelayInfo(relay)} + className={styles.showInfo} + > + Show info{' '} + {displayRelaysInfo.includes(relay) ? ( + + ) : ( + + )} + + ) : ( + '' + ) + } + /> handleRelayWriteChange(relay, event)} /> + {displayRelaysInfo.includes(relay) && ( + <> + + + + {relaysInfo && + relaysInfo[relay] && + Object.keys(relaysInfo[relay]).map((key: string) => { + const infoTitle = capitalizeFirstLetter( + key.replace('_', ' ') + ) + let infoValue = (relaysInfo[relay] as any)[key] + + switch (key) { + case 'pubkey': + infoValue = shorten(hexToNpub(infoValue), 15) + + break + + case 'limitation': + infoValue = ( +
    + {Object.keys(infoValue).map((valueKey) => ( +
  • + + {capitalizeFirstLetter( + valueKey.split('_').join(' ') + )} + : + {' '} + {`${infoValue[valueKey]}`} +
  • + ))} +
+ ) + + break + + case 'fees': + infoValue = ( +
    + {Object.keys(infoValue).map((valueKey) => ( +
  • + + {capitalizeFirstLetter( + valueKey.split('_').join(' ') + )} + : + {' '} + {`${infoValue[valueKey].map((fee: RelayFee) => `${fee.amount} ${fee.unit}`)}`} +
  • + ))} +
+ ) + break + default: + break + } + + if (Array.isArray(infoValue)) { + infoValue = infoValue.join(', ') + } + + return ( + + + {infoTitle}: + {' '} + {infoValue} + {key === 'pubkey' ? ( + { + navigator.clipboard.writeText( + hexToNpub( + (relaysInfo[relay] as any)[key] + ) + ) + + toast.success('Copied to clipboard', { + autoClose: 1000, + hideProgressBar: true + }) + }} + /> + ) : null} + + ) + })} +
+
+ + )}
))} diff --git a/src/pages/relays/style.module.scss b/src/pages/relays/style.module.scss index dd9d706..6fcb8b7 100644 --- a/src/pages/relays/style.module.scss +++ b/src/pages/relays/style.module.scss @@ -7,6 +7,13 @@ width: 100%; } + .relayAddContainer { + display: flex; + flex-direction: row; + gap: 10px; + width: 100%; + } + .sectionIcon { font-size: 30px; } @@ -43,5 +50,57 @@ gap: 10px; cursor: pointer; } + + .showInfo { + cursor: pointer; + } + + .showInfoIcon { + margin-right: 3px; + margin-bottom: auto; + vertical-align: middle; + } + + .relayInfoContainer { + display: flex; + flex-direction: column; + gap: 5px; + text-wrap: wrap; + } + + .relayInfoTitle { + font-weight: 600; + } + + .relayInfoSubTitle { + font-weight: 500; + } + + .copyItem { + margin-left: 10px; + color: #34495e; + vertical-align: bottom; + cursor: pointer; + } + + .connectionStatus { + border-radius: 9999px; + width: 10px; + height: 10px; + margin-right: 5px; + margin-top: 2px; + } + + .connectionStatusConnected { + background-color: $review-feedback-correct; + } + + .connectionStatusNotConnected { + background-color: $review-feedback-incorrect; + } + + .connectionStatusUnknown { + background-color: $input-text-color; + } } } diff --git a/src/utils/string.ts b/src/utils/string.ts index 20337ea..e27d50c 100644 --- a/src/utils/string.ts +++ b/src/utils/string.ts @@ -78,3 +78,11 @@ export const parseJson = (content: string): Promise => { } }) } + +/** + * Capitalizes the first character in the string + * @param str string to modify + * @returns modified string + */ +export const capitalizeFirstLetter = (str: string) => + str.charAt(0).toUpperCase() + str.slice(1).toLowerCase() -- 2.34.1 From 5d17ae8d4af3d579debb36995f2a545b5a7f5627 Mon Sep 17 00:00:00 2001 From: Yury Date: Fri, 24 May 2024 15:57:03 +0300 Subject: [PATCH 7/8] feat(Relay): added methods to get info, most popular, connect and disconnect from relays --- src/controllers/AuthController.ts | 25 +-- src/controllers/NostrController.ts | 320 ++++++++++++++++++++++++++--- 2 files changed, 304 insertions(+), 41 deletions(-) diff --git a/src/controllers/AuthController.ts b/src/controllers/AuthController.ts index 34b2f7a..c2efb4c 100644 --- a/src/controllers/AuthController.ts +++ b/src/controllers/AuthController.ts @@ -74,25 +74,16 @@ export class AuthController { }) ) - const relaysState = store.getState().relays + const relayMap = await this.nostrController.getRelayMap(pubkey) - 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 if relay map is empty + return Promise.resolve(appPrivateRoutes.relays) + } - if (Object.keys(relayMap).length < 1) { - // Navigate user to relays page - return Promise.resolve(appPrivateRoutes.relays) - } - - store.dispatch(setRelayMapAction(relayMap.map)) + if (store.getState().auth?.loggedIn) { + if (!compareObjects(store.getState().relays?.map, relayMap.map)) + store.dispatch(setRelayMapAction(relayMap.map)) } const visitedLink = getVisitedLink() diff --git a/src/controllers/NostrController.ts b/src/controllers/NostrController.ts index 4dcf651..1b80595 100644 --- a/src/controllers/NostrController.ts +++ b/src/controllers/NostrController.ts @@ -3,7 +3,8 @@ import NDK, { NDKNip46Signer, NDKPrivateKeySigner, NDKUser, - NostrEvent + NostrEvent, + NDKSubscription } from '@nostr-dev-kit/ndk' import { Event, @@ -11,25 +12,45 @@ import { SimplePool, UnsignedEvent, Filter, + Relay, finalizeEvent, nip04, nip19, kinds } from 'nostr-tools' import { EventEmitter } from 'tseep' -import { updateNsecbunkerPubkey } from '../store/actions' +import { + updateNsecbunkerPubkey, + setMostPopularRelaysAction, + setRelayInfoAction, + setRelayConnectionStatusAction +} from '../store/actions' import { AuthState, LoginMethods } from '../store/auth/types' import store from '../store/store' -import { SignedEvent, RelayMap } from '../types' -import { getNsecBunkerDelegatedKey, verifySignedEvent } from '../utils' +import { + SignedEvent, + RelayMap, + RelayStats, + ReadRelay, + RelayInfoObject, + RelayConnectionStatus, + RelayConnectionState +} from '../types' +import { + compareObjects, + getNsecBunkerDelegatedKey, + verifySignedEvent +} from '../utils' +import axios from 'axios' export class NostrController extends EventEmitter { private static instance: NostrController - private specialMetadataRelay = 'wss://purplepag.es' private bunkerNDK: NDK | undefined private remoteSigner: NDKNip46Signer | undefined + private connectedRelays: Relay[] | undefined + private constructor() { super() } @@ -390,12 +411,7 @@ export class NostrController extends EventEmitter { 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 mostPopularRelays = await this.getMostPopularRelays() const pool = new SimplePool() @@ -405,9 +421,11 @@ export class NostrController extends EventEmitter { authors: [npub] } - const event = await pool.get(popularRelayURIs, eventFilter).catch((err) => { - return Promise.reject(err) - }) + const event = await pool + .get(mostPopularRelays, eventFilter) + .catch((err) => { + return Promise.reject(err) + }) if (event) { // Handle founded 10002 event @@ -427,6 +445,10 @@ export class NostrController extends EventEmitter { } }) + this.getRelayInfo(Object.keys(relaysMap)) + + this.connectToRelays(Object.keys(relaysMap)) + return Promise.resolve({ map: relaysMap, mapUpdated: event.created_at }) } else { return Promise.reject('User relays were not found.') @@ -437,11 +459,13 @@ export class NostrController extends EventEmitter { * Publishes relay map. * @param relayMap - relay map. * @param npub - user's npub. + * @param extraRelaysToPublish - optional relays to publish relay map. * @returns - promise that resolves into a string representing publishing result. */ publishRelayMap = async ( relayMap: RelayMap, - npub: string + npub: string, + extraRelaysToPublish?: string[] ): Promise => { const timestamp = Math.floor(Date.now() / 1000) const relayURIs = Object.keys(relayMap) @@ -471,18 +495,266 @@ export class NostrController extends EventEmitter { 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] + // Add extra relays if provided + if (extraRelaysToPublish) { + relaysToPublish = [...relaysToPublish, ...extraRelaysToPublish] } - await this.publishEvent(signedEvent, relaysToPublish) + // If relay map is empty, use most popular relay URIs + if (!relaysToPublish.length) { + relaysToPublish = await this.getMostPopularRelays() + } - return Promise.resolve( - `Relay Map published on: ${relaysToPublish.join('\n')}` + const publishResult = await this.publishEvent(signedEvent, relaysToPublish) + + if (publishResult && publishResult.length) { + return Promise.resolve( + `Relay Map published on: ${publishResult.join('\n')}` + ) + } + + return Promise.reject('Publishing updated relay map was unsuccessful.') + } + + /** + * Provides most popular relays. + * @param numberOfTopRelays - number representing how many most popular relays to provide + * @returns - promise that resolves into an array of most popular relays + */ + getMostPopularRelays = async ( + numberOfTopRelays: number = 30 + ): Promise => { + const mostPopularRelaysState = store.getState().relays?.mostPopular + + // return most popular relays from app state if present + if (mostPopularRelaysState) return mostPopularRelaysState + + // relays in env + const { VITE_MOST_POPULAR_RELAYS } = import.meta.env + const hardcodedPopularRelays = (VITE_MOST_POPULAR_RELAYS || '').split(' ') + const url = `https://stats.nostr.band/stats_api?method=stats` + + const response = await axios.get(url).catch(() => undefined) + + if (!response) { + return hardcodedPopularRelays //return hardcoded relay list + } + + const data = response.data + + if (!data) { + return hardcodedPopularRelays //return hardcoded relay list + } + + const apiTopRelays = data.relay_stats.user_picks.read_relays + .slice(0, numberOfTopRelays) + .map((relay: ReadRelay) => relay.d) + + if (!apiTopRelays.length) { + return Promise.reject(`Couldn't fetch popular relays.`) + } + + if (store.getState().auth?.loggedIn) { + store.dispatch(setMostPopularRelaysAction(apiTopRelays)) + } + + return apiTopRelays + } + + /** + * Sets information about relays into relays.info app state. + * @param relayURIs - relay URIs to get information about + */ + getRelayInfo = async (relayURIs: string[]) => { + // initialize job request + const jobEventTemplate: EventTemplate = { + content: '', + created_at: Math.round(Date.now() / 1000), + kind: 68001, + tags: [ + ['i', `${JSON.stringify(relayURIs)}`], + ['j', 'relay-info'] + ] + } + + // sign job request event + const jobSignedEvent = await this.signEvent(jobEventTemplate) + + const relays = [ + 'wss://relay.damus.io', + 'wss://relay.primal.net', + 'wss://relayable.org' + ] + + // publish job request + await this.publishEvent(jobSignedEvent, relays) + + console.log('jobSignedEvent :>> ', jobSignedEvent) + + const subscribeWithTimeout = ( + subscription: NDKSubscription, + timeoutMs: number + ): Promise => { + return new Promise((resolve, reject) => { + const eventHandler = (event: NDKEvent) => { + subscription.stop() + resolve(event.content) + } + + subscription.on('event', eventHandler) + + // Set up a timeout to stop the subscription after a specified time + const timeout = setTimeout(() => { + subscription.stop() // Stop the subscription + reject(new Error('Subscription timed out')) // Reject the promise with a timeout error + }, timeoutMs) + + // Handle subscription close event + subscription.on('close', () => clearTimeout(timeout)) + }) + } + + const dvmNDK = new NDK({ + explicitRelayUrls: relays + }) + + await dvmNDK.connect(2000) + + // filter for getting DVM job's result + const sub = dvmNDK.subscribe({ + kinds: [68002 as number], + '#e': [jobSignedEvent.id], + '#p': [jobSignedEvent.pubkey] + }) + + // asynchronously get block number from dvm job with 20 seconds timeout + const dvmJobResult = await subscribeWithTimeout(sub, 20000) + + if (!dvmJobResult) { + return Promise.reject(`Relay(s) information wasn't received`) + } + + let relaysInfo: RelayInfoObject + + try { + relaysInfo = JSON.parse(dvmJobResult) + } catch (error) { + return Promise.reject(`Invalid relay(s) information.`) + } + + if ( + relaysInfo && + !compareObjects(store.getState().relays?.info, relaysInfo) + ) { + store.dispatch(setRelayInfoAction(relaysInfo)) + } + } + + /** + * Establishes connection to relays. + * @param relayURIs - an array of relay URIs + * @returns - promise that resolves into an array of connections + */ + connectToRelays = async (relayURIs: string[]) => { + // Copy of relay connection status + const relayConnectionsStatus: RelayConnectionStatus = JSON.parse( + JSON.stringify(store.getState().relays?.connectionStatus || {}) ) + + const connectedRelayURLs = this.connectedRelays + ? this.connectedRelays.map((relay) => relay.url) + : [] + + // Check if connections already established + if (compareObjects(connectedRelayURLs, relayURIs)) { + return + } + + const connections = relayURIs + .filter((relayURI) => !connectedRelayURLs.includes(relayURI)) + .map((relayURI) => + Relay.connect(relayURI) + .then((relay) => { + // put connection status into relayConnectionsStatus object + relayConnectionsStatus[relayURI] = relay.connected + ? RelayConnectionState.Connected + : RelayConnectionState.NotConnected + + return relay + }) + .catch(() => { + relayConnectionsStatus[relayURI] = RelayConnectionState.NotConnected + }) + ) + + const connected = await Promise.all(connections) + + // put connected relays into connectedRelays private property, so it can be closed later + this.connectedRelays = connected.filter( + (relay) => relay instanceof Relay && relay.connected + ) as Relay[] + + if (Object.keys(relayConnectionsStatus)) { + if ( + !compareObjects( + store.getState().relays?.connectionStatus, + relayConnectionsStatus + ) + ) { + store.dispatch(setRelayConnectionStatusAction(relayConnectionsStatus)) + } + } + + return Promise.resolve(relayConnectionsStatus) + } + + /** + * Disconnects from relays. + * @param relayURIs - array of relay URIs to disconnect from + */ + disconnectFromRelays = async (relayURIs: string[]) => { + const connectedRelayURLs = this.connectedRelays + ? this.connectedRelays.map((relay) => relay.url) + : [] + + relayURIs + .filter((relayURI) => connectedRelayURLs.includes(relayURI)) + .forEach((relayURI) => { + if (this.connectedRelays) { + const relay = this.connectedRelays.find( + (relay) => relay.url === relayURI + ) + + if (relay) { + // close relay connection + relay.close() + + // remove relay from connectedRelays property + this.connectedRelays = this.connectedRelays.filter( + (relay) => relay.url !== relayURI + ) + } + } + }) + + if (store.getState().relays?.connectionStatus) { + const connectionStatus = JSON.parse( + JSON.stringify(store.getState().relays?.connectionStatus) + ) + + relayURIs.forEach((relay) => { + delete connectionStatus[relay] + }) + + if ( + !compareObjects( + store.getState().relays?.connectionStatus, + connectionStatus + ) + ) { + // Update app state + store.dispatch(setRelayConnectionStatusAction(connectionStatus)) + } + } } } -- 2.34.1 From 5a8c6c8183fd53522c5df14319450ba626946b95 Mon Sep 17 00:00:00 2001 From: Yury Date: Fri, 24 May 2024 16:05:52 +0300 Subject: [PATCH 8/8] chore: added changes from main --- src/routes/index.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 01c3948..4753835 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -13,6 +13,7 @@ export const appPrivateRoutes = { create: '/create', sign: '/sign', verify: '/verify', + profileSettings: '/settings/profile/:npub', relays: '/relays' } @@ -26,6 +27,9 @@ export const appPublicRoutes = { export const getProfileRoute = (hexKey: string) => appPublicRoutes.profile.replace(':npub', hexToNpub(hexKey)) +export const getProfileSettingsRoute = (hexKey: string) => + appPrivateRoutes.profileSettings.replace(':npub', hexToNpub(hexKey)) + export const publicRoutes = [ { path: appPublicRoutes.landingPage, @@ -60,6 +64,10 @@ export const privateRoutes = [ path: appPrivateRoutes.verify, element: }, + { + path: appPrivateRoutes.profileSettings, + element: + }, { path: appPrivateRoutes.relays, element: -- 2.34.1