feat(Relays): improved relays page

This commit is contained in:
Yury 2024-05-24 15:39:37 +03:00
parent d3d87be2c3
commit 8790a943c3
3 changed files with 381 additions and 36 deletions

View File

@ -6,12 +6,32 @@ import Switch from '@mui/material/Switch'
import ListItemText from '@mui/material/ListItemText' import ListItemText from '@mui/material/ListItemText'
import Divider from '@mui/material/Divider' import Divider from '@mui/material/Divider'
import { NostrController } from '../../controllers' import { NostrController } from '../../controllers'
import { RelayMap } from '../../types' import {
RelayMap,
RelayInfoObject,
RelayFee,
RelayConnectionState
} from '../../types'
import LogoutIcon from '@mui/icons-material/Logout' import LogoutIcon from '@mui/icons-material/Logout'
import { useAppSelector, useAppDispatch } from '../../hooks' import { useAppSelector, useAppDispatch } from '../../hooks'
import { compareObjects } from '../../utils' import {
import { setRelayMapAction } from '../../store/actions' compareObjects,
shorten,
hexToNpub,
capitalizeFirstLetter
} from '../../utils'
import {
setRelayMapAction,
setRelayMapUpdatedAction
} from '../../store/actions'
import { toast } from 'react-toastify' 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 = () => { export const RelaysPage = () => {
const nostrController = NostrController.getInstance() const nostrController = NostrController.getInstance()
@ -26,6 +46,30 @@ export const RelaysPage = () => {
const [relayMap, setRelayMap] = useState<RelayMap | undefined>( const [relayMap, setRelayMap] = useState<RelayMap | undefined>(
relaysState?.map relaysState?.map
) )
const [relaysInfo, setRelaysInfo] = useState<RelayInfoObject | undefined>(
relaysState?.info
)
const [displayRelaysInfo, setDisplayRelaysInfo] = useState<string[]>([])
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(() => { useEffect(() => {
let isMounted = false let isMounted = false
@ -42,7 +86,7 @@ export const RelaysPage = () => {
if ( if (
!relaysState?.mapUpdated || !relaysState?.mapUpdated ||
newRelayMap.mapUpdated > relaysState?.mapUpdated newRelayMap.mapUpdated > relaysState?.mapUpdated
) ) {
if ( if (
!relaysState?.map || !relaysState?.map ||
!compareObjects(relaysState.map, newRelayMap) !compareObjects(relaysState.map, newRelayMap)
@ -50,6 +94,10 @@ export const RelaysPage = () => {
setRelayMap(newRelayMap.map) setRelayMap(newRelayMap.map)
dispatch(setRelayMapAction(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 Date.now() - relaysState?.mapUpdated > 5 * 60 * 1000) // 5 minutes
) { ) {
fetchData() 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 // cleanup func
return () => { return () => {
isMounted = false isMounted = false
} }
}, [dispatch, usersPubkey, relaysState, nostrController]) }, [
dispatch,
usersPubkey,
relaysState?.map,
relaysState?.mapUpdated,
nostrController,
relaysConnectionStatus
])
useEffect(() => { useEffect(() => {
// Display notification if an empty relay map has been received // Display notification if an empty relay map has been received
@ -83,26 +150,39 @@ export const RelaysPage = () => {
const handleLeaveRelay = async (relay: string) => { const handleLeaveRelay = async (relay: string) => {
if (relayMap) { 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() relayRequirementWarning()
} else { } else {
const relayMapCopy = JSON.parse(JSON.stringify(relayMap)) const relayMapCopy = JSON.parse(JSON.stringify(relayMap))
// Remove relay from relay map // Remove relay from relay map
delete relayMapCopy[relay] delete relayMapCopy[relay]
setRelayMap(relayMapCopy)
dispatch(setRelayMapAction(relayMapCopy))
if (usersPubkey) { if (usersPubkey) {
// Publish updated relay map. // Publish updated relay map.
const relayMapPublishingRes = await nostrController const relayMapPublishingRes = await nostrController
.publishRelayMap(relayMapCopy, usersPubkey) .publishRelayMap(relayMapCopy, usersPubkey, [relay])
.catch((err) => handlePublishRelayMapError(err)) .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)) const relayMapCopy = JSON.parse(JSON.stringify(relayMap))
relayMapCopy[relay].write = event.target.checked relayMapCopy[relay].write = event.target.checked
setRelayMap(relayMapCopy)
dispatch(setRelayMapAction(relayMapCopy))
if (usersPubkey) { if (usersPubkey) {
// Publish updated relay map // Publish updated relay map
const relayMapPublishingRes = await nostrController const relayMapPublishingRes = await nostrController
.publishRelayMap(relayMapCopy, usersPubkey) .publishRelayMap(relayMapCopy, usersPubkey)
.catch((err) => handlePublishRelayMapError(err)) .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 // Check if new relay URI is a valid string
if ( if (
newRelayURI && relayURI &&
!/^wss:\/\/[-a-zA-Z0-9@:%._\\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}/.test( !/^wss:\/\/[-a-zA-Z0-9@:%._\\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}/.test(
newRelayURI relayURI
) )
) { ) {
if (relayURI !== webSocketPrefix) {
setNewRelayURIerror( setNewRelayURIerror(
'New relay URI is not valid. Example of valid relay URI: wss://sigit.relay.io' 'New relay URI is not valid. Example of valid relay URI: wss://sigit.relay.io'
) )
} else if (newRelayURI) { }
} 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)) const relayMapCopy = JSON.parse(JSON.stringify(relayMap))
relayMapCopy[newRelayURI.trim()] = { write: true, read: true } 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) setRelayMap(relayMapCopy)
setNewRelayURI('') setNewRelayURI('')
dispatch(setRelayMapAction(relayMapCopy)) dispatch(setRelayMapAction(relayMapCopy))
if (usersPubkey) { nostrController.getRelayInfo([relayURI])
// Publish updated relay map
const relayMapPublishingRes = await nostrController
.publishRelayMap(relayMapCopy, usersPubkey)
.catch((err) => handlePublishRelayMapError(err))
if (relayMapPublishingRes) toast.success(relayMapPublishingRes) 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 ( return (
<Box className={styles.container}> <Box className={styles.container}>
<Box> <Box className={styles.relayAddContainer}>
<TextField <TextField
label="Add new relay" label="Add new relay"
value={newRelayURI} value={newRelayURI}
onBlur={() => handleTextFieldChange()}
onChange={(e) => setNewRelayURI(e.target.value)} onChange={(e) => setNewRelayURI(e.target.value)}
helperText={newRelayURIerror} helperText={newRelayURIerror}
error={!!newRelayURIerror} error={!!newRelayURIerror}
placeholder="wss://" InputProps={{
startAdornment: (
<InputAdornment position="start">
{webSocketPrefix}
</InputAdornment>
)
}}
className={styles.relayURItextfield} className={styles.relayURItextfield}
/> />
<Button variant="contained" onClick={() => handleAddNewRelay()}>
Add
</Button>
</Box> </Box>
<Box className={styles.sectionTitle}> <Box className={styles.sectionTitle}>
<RouterIcon className={styles.sectionIcon} /> <RouterIcon className={styles.sectionIcon} />
@ -207,7 +340,32 @@ export const RelaysPage = () => {
<Box className={styles.relay} key={`relay_${i}`}> <Box className={styles.relay} key={`relay_${i}`}>
<List> <List>
<ListItem> <ListItem>
<span
className={[
styles.connectionStatus,
relaysConnectionStatus
? relaysConnectionStatus[relay] ===
RelayConnectionState.Connected
? styles.connectionStatusConnected
: styles.connectionStatusNotConnected
: styles.connectionStatusUnknown
].join(' ')}
/>
{relaysInfo &&
relaysInfo[relay] &&
relaysInfo[relay].limitation &&
relaysInfo[relay].limitation?.payment_required && (
<Tooltip title="Paid Relay" arrow placement="top">
<ElectricBoltIcon
className={styles.lightningIcon}
color="warning"
onClick={() => handleRelayInfo(relay)}
/>
</Tooltip>
)}
<ListItemText primary={relay} /> <ListItemText primary={relay} />
<Box <Box
className={styles.leaveRelayContainer} className={styles.leaveRelayContainer}
onClick={() => handleLeaveRelay(relay)} onClick={() => handleLeaveRelay(relay)}
@ -218,12 +376,132 @@ export const RelaysPage = () => {
</ListItem> </ListItem>
<Divider className={styles.relayDivider} /> <Divider className={styles.relayDivider} />
<ListItem> <ListItem>
<ListItemText primary="Publish to this relay?" /> <ListItemText
primary="Publish to this relay?"
secondary={
relaysInfo && relaysInfo[relay] ? (
<span
onClick={() => handleRelayInfo(relay)}
className={styles.showInfo}
>
Show info{' '}
{displayRelaysInfo.includes(relay) ? (
<KeyboardArrowUpIcon
className={styles.showInfoIcon}
/>
) : (
<KeyboardArrowDownIcon
className={styles.showInfoIcon}
/>
)}
</span>
) : (
''
)
}
/>
<Switch <Switch
checked={relayMap[relay].write} checked={relayMap[relay].write}
onChange={(event) => handleRelayWriteChange(relay, event)} onChange={(event) => handleRelayWriteChange(relay, event)}
/> />
</ListItem> </ListItem>
{displayRelaysInfo.includes(relay) && (
<>
<Divider className={styles.relayDivider} />
<ListItem>
<Box className={styles.relayInfoContainer}>
{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 = (
<ul key={`${i}_${key}`}>
{Object.keys(infoValue).map((valueKey) => (
<li key={`${i}_${key}_${valueKey}`}>
<span
className={styles.relayInfoSubTitle}
>
{capitalizeFirstLetter(
valueKey.split('_').join(' ')
)}
:
</span>{' '}
{`${infoValue[valueKey]}`}
</li>
))}
</ul>
)
break
case 'fees':
infoValue = (
<ul>
{Object.keys(infoValue).map((valueKey) => (
<li key={`${i}_${key}_${valueKey}`}>
<span
className={styles.relayInfoSubTitle}
>
{capitalizeFirstLetter(
valueKey.split('_').join(' ')
)}
:
</span>{' '}
{`${infoValue[valueKey].map((fee: RelayFee) => `${fee.amount} ${fee.unit}`)}`}
</li>
))}
</ul>
)
break
default:
break
}
if (Array.isArray(infoValue)) {
infoValue = infoValue.join(', ')
}
return (
<span key={`${i}_${key}_container`}>
<span className={styles.relayInfoTitle}>
{infoTitle}:
</span>{' '}
{infoValue}
{key === 'pubkey' ? (
<ContentCopyIcon
className={styles.copyItem}
onClick={() => {
navigator.clipboard.writeText(
hexToNpub(
(relaysInfo[relay] as any)[key]
)
)
toast.success('Copied to clipboard', {
autoClose: 1000,
hideProgressBar: true
})
}}
/>
) : null}
</span>
)
})}
</Box>
</ListItem>
</>
)}
</List> </List>
</Box> </Box>
))} ))}

View File

@ -7,6 +7,13 @@
width: 100%; width: 100%;
} }
.relayAddContainer {
display: flex;
flex-direction: row;
gap: 10px;
width: 100%;
}
.sectionIcon { .sectionIcon {
font-size: 30px; font-size: 30px;
} }
@ -43,5 +50,57 @@
gap: 10px; gap: 10px;
cursor: pointer; 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;
}
} }
} }

View File

@ -78,3 +78,11 @@ export const parseJson = <T>(content: string): Promise<T> => {
} }
}) })
} }
/**
* 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()