diff --git a/src/components/AppBar/AppBar.tsx b/src/components/AppBar/AppBar.tsx index 78f4f01..a7b91bd 100644 --- a/src/components/AppBar/AppBar.tsx +++ b/src/components/AppBar/AppBar.tsx @@ -10,17 +10,24 @@ 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' 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, saveNsecBunkerDelegatedKey, shorten } from '../../utils' @@ -92,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() @@ -160,6 +168,18 @@ export const AppBar = () => { > Profile + { + setAnchorElUser(null) + + navigate(appPrivateRoutes.relays) + }} + sx={{ + justifyContent: 'center' + }} + > + Relays + { - console.error(err) - return null - }) + const events = await pool + .querySync(mostPopularRelays, eventFilter) + .catch((err) => { + console.error(err) + + return null + }) if (events && events.length) { events.sort((a, b) => b.created_at - a.created_at) @@ -96,14 +97,15 @@ export class MetadataController { }) if (!relayEvent) { - const mostPopularRelays = import.meta.env.VITE_MOST_POPULAR_RELAYS - const hardcodedPopularRelays = (mostPopularRelays || '').split(' ') - const relays = [...hardcodedPopularRelays] + const mostPopularRelays = + await this.nostrController.getMostPopularRelays() - relayEvent = await pool.get(relays, eventFilter).catch((err) => { - console.error(err) - return null - }) + relayEvent = await pool + .get(mostPopularRelays, eventFilter) + .catch((err) => { + console.error(err) + return null + }) } if (relayEvent) { diff --git a/src/controllers/NostrController.ts b/src/controllers/NostrController.ts index 7980f67..1b80595 100644 --- a/src/controllers/NostrController.ts +++ b/src/controllers/NostrController.ts @@ -3,23 +3,45 @@ import NDK, { NDKNip46Signer, NDKPrivateKeySigner, NDKUser, - NostrEvent + NostrEvent, + NDKSubscription } from '@nostr-dev-kit/ndk' import { Event, EventTemplate, SimplePool, UnsignedEvent, + Filter, + Relay, finalizeEvent, nip04, - nip19 + 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 } 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 @@ -27,6 +49,8 @@ export class NostrController extends EventEmitter { private bunkerNDK: NDK | undefined private remoteSigner: NDKNip46Signer | undefined + private connectedRelays: Relay[] | undefined + private constructor() { super() } @@ -216,12 +240,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 +402,359 @@ 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 = await this.getMostPopularRelays() + + 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(mostPopularRelays, 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 + } + }) + + 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.') + } + } + + /** + * 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, + extraRelaysToPublish?: 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 + + // Add extra relays if provided + if (extraRelaysToPublish) { + relaysToPublish = [...relaysToPublish, ...extraRelaysToPublish] + } + + // If relay map is empty, use most popular relay URIs + if (!relaysToPublish.length) { + relaysToPublish = await this.getMostPopularRelays() + } + + 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)) + } + } + } } 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/relays/index.tsx b/src/pages/relays/index.tsx new file mode 100644 index 0000000..c5ba03f --- /dev/null +++ b/src/pages/relays/index.tsx @@ -0,0 +1,518 @@ +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, + RelayInfoObject, + RelayFee, + RelayConnectionState +} from '../../types' +import LogoutIcon from '@mui/icons-material/Logout' +import { useAppSelector, useAppDispatch } from '../../hooks' +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() + + 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 + ) + 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(() => { + if (!compareObjects(relayMap, relaysState?.map)) { + setRelayMap(relaysState?.map) + } + }, [relayMap, 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)) + } else { + // Update relay map updated timestamp + dispatch(setRelayMapUpdatedAction()) + } + } + } + } + } + + // 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() + + // 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?.map, + relaysState?.mapUpdated, + nostrController, + relaysConnectionStatus + ]) + + 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) { + 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] + + if (usersPubkey) { + // Publish updated relay map. + const relayMapPublishingRes = await nostrController + .publishRelayMap(relayMapCopy, usersPubkey, [relay]) + .catch((err) => handlePublishRelayMapError(err)) + + if (relayMapPublishingRes) { + toast.success(relayMapPublishingRes) + + setRelayMap(relayMapCopy) + + dispatch(setRelayMapAction(relayMapCopy)) + } + } + + nostrController.disconnectFromRelays([relay]) + } + } + } + + 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 + + if (usersPubkey) { + // Publish updated relay map + const relayMapPublishingRes = await nostrController + .publishRelayMap(relayMapCopy, usersPubkey) + .catch((err) => handlePublishRelayMapError(err)) + + if (relayMapPublishingRes) { + toast.success(relayMapPublishingRes) + + setRelayMap(relayMapCopy) + + dispatch(setRelayMapAction(relayMapCopy)) + } + } + } + } + } + + const handleAddNewRelay = async () => { + const relayURI = `${webSocketPrefix}${newRelayURI?.trim().replace(webSocketPrefix, '')}` + + // Check if new relay URI is a valid string + if ( + relayURI && + !/^wss:\/\/[-a-zA-Z0-9@:%._\\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}/.test( + relayURI + ) + ) { + 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]) + + if ( + connectionStatus && + connectionStatus[relayURI] && + connectionStatus[relayURI] === RelayConnectionState.Connected + ) { + const relayMapCopy = JSON.parse(JSON.stringify(relayMap)) + + relayMapCopy[relayURI] = { write: true, read: true } + + // Publish updated relay map + const relayMapPublishingRes = await nostrController + .publishRelayMap(relayMapCopy, usersPubkey) + .catch((err) => handlePublishRelayMapError(err)) + + 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 ( + + + setNewRelayURI(e.target.value)} + helperText={newRelayURIerror} + error={!!newRelayURIerror} + InputProps={{ + startAdornment: ( + + {webSocketPrefix} + + ) + }} + className={styles.relayURItextfield} + /> + + + + + YOUR RELAYS + + {relayMap && ( + + {Object.keys(relayMap).map((relay, i) => ( + + + + + {relaysInfo && + relaysInfo[relay] && + relaysInfo[relay].limitation && + relaysInfo[relay].limitation?.payment_required && ( + + handleRelayInfo(relay)} + /> + + )} + + + + handleLeaveRelay(relay)} + > + + Leave + + + + + 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 new file mode 100644 index 0000000..25d6347 --- /dev/null +++ b/src/pages/relays/style.module.scss @@ -0,0 +1,107 @@ +@import '../../colors.scss'; + +.container { + margin-top: 25px; + color: $text-color; + + .relayURItextfield { + width: 100%; + } + + .relayAddContainer { + display: flex; + flex-direction: row; + gap: 10px; + 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; + } + + .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/routes/index.tsx b/src/routes/index.tsx index a88732f..e69f13a 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -7,13 +7,15 @@ import { hexToNpub } from '../utils' import { SignPage } from '../pages/sign' import { VerifyPage } from '../pages/verify' import { ProfileSettingsPage } from '../pages/settings/profile' +import { RelaysPage } from '../pages/relays' export const appPrivateRoutes = { homePage: '/', create: '/create', sign: '/sign', verify: '/verify', - profileSettings: '/settings/profile/:npub' + profileSettings: '/settings/profile/:npub', + relays: '/relays' } export const appPublicRoutes = { @@ -66,5 +68,9 @@ export const privateRoutes = [ { path: appPrivateRoutes.profileSettings, element: + }, + { + path: appPrivateRoutes.relays, + element: } ] diff --git a/src/store/actionTypes.ts b/src/store/actionTypes.ts index 6e0cc66..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' @@ -9,3 +11,9 @@ 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' +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/actions.ts b/src/store/actions.ts index 3bf716b..a101199 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 { @@ -15,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/relays/action.ts b/src/store/relays/action.ts new file mode 100644 index 0000000..6f95840 --- /dev/null +++ b/src/store/relays/action.ts @@ -0,0 +1,39 @@ +import * as ActionTypes from '../actionTypes' +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 new file mode 100644 index 0000000..b4b9854 --- /dev/null +++ b/src/store/relays/reducer.ts @@ -0,0 +1,46 @@ +import * as ActionTypes from '../actionTypes' +import { RelaysDispatchTypes, RelaysState } from './types' + +const initialState: RelaysState = { + map: undefined, + mapUpdated: undefined, + mostPopular: undefined, + info: undefined, + connectionStatus: undefined +} + +const reducer = ( + state = initialState, + action: RelaysDispatchTypes +): RelaysState | null => { + switch (action.type) { + case ActionTypes.SET_RELAY_MAP: + 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 + + 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..e1c4da8 --- /dev/null +++ b/src/store/relays/types.ts @@ -0,0 +1,43 @@ +import * as ActionTypes from '../actionTypes' +import { RestoreState } from '../actions' +import { RelayMap, RelayInfoObject, RelayConnectionStatus } from '../../types' + +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 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 diff --git a/src/store/rootReducer.ts b/src/store/rootReducer.ts index 03c9b5c..517291c 100644 --- a/src/store/rootReducer.ts +++ b/src/store/rootReducer.ts @@ -4,15 +4,31 @@ 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' +import * as ActionTypes from './actionTypes' export interface State { auth: AuthState metadata?: Event userRobotImage?: string + relays: RelaysState } -export default combineReducers({ +export const appReducer = combineReducers({ auth: authReducer, metadata: metadataReducer, - userRobotImage: userRobotImageReducer + 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) + } +} 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/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 67572d8..a7d6f0c 100644 --- a/src/types/nostr.ts +++ b/src/types/nostr.ts @@ -8,11 +8,6 @@ export interface SignedEvent { sig: string } -export interface RelaySet { - read: string[] - write: string[] -} - export interface NostrJoiningBlock { block: number encodedEventPointer: string 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 +} 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/string.ts b/src/utils/string.ts index 97e86ea..09a9313 100644 --- a/src/utils/string.ts +++ b/src/utils/string.ts @@ -85,3 +85,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() 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) +}