import ContentCopyIcon from '@mui/icons-material/ContentCopy' import ElectricBoltIcon from '@mui/icons-material/ElectricBolt' import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown' import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp' import LogoutIcon from '@mui/icons-material/Logout' import RouterIcon from '@mui/icons-material/Router' import { Box, List, ListItem, TextField, Tooltip } from '@mui/material' import Button from '@mui/material/Button' import Divider from '@mui/material/Divider' import InputAdornment from '@mui/material/InputAdornment' import ListItemText from '@mui/material/ListItemText' import Switch from '@mui/material/Switch' import { useEffect, useState } from 'react' import { toast } from 'react-toastify' import { NostrController } from '../../../controllers' import { useAppDispatch, useAppSelector } from '../../../hooks' import { setRelayMapAction, setRelayMapUpdatedAction } from '../../../store/actions' import { RelayConnectionState, RelayFee, RelayInfoObject, RelayMap } from '../../../types' import { capitalizeFirstLetter, compareObjects, hexToNpub, shorten } from '../../../utils' import styles from './style.module.scss' 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} ) })}
)}
))}
)}
) }