518 lines
17 KiB
TypeScript
518 lines
17 KiB
TypeScript
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<string>()
|
|
const [newRelayURIerror, setNewRelayURIerror] = useState<string>()
|
|
const [relayMap, setRelayMap] = useState<RelayMap | undefined>(
|
|
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(() => {
|
|
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<HTMLInputElement>
|
|
) => {
|
|
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 (
|
|
<Box className={styles.container}>
|
|
<Box className={styles.relayAddContainer}>
|
|
<TextField
|
|
label="Add new relay"
|
|
value={newRelayURI}
|
|
onChange={(e) => setNewRelayURI(e.target.value)}
|
|
helperText={newRelayURIerror}
|
|
error={!!newRelayURIerror}
|
|
InputProps={{
|
|
startAdornment: (
|
|
<InputAdornment position="start">
|
|
{webSocketPrefix}
|
|
</InputAdornment>
|
|
)
|
|
}}
|
|
className={styles.relayURItextfield}
|
|
/>
|
|
<Button variant="contained" onClick={() => handleAddNewRelay()}>
|
|
Add
|
|
</Button>
|
|
</Box>
|
|
<Box className={styles.sectionTitle}>
|
|
<RouterIcon className={styles.sectionIcon} />
|
|
<span>YOUR RELAYS</span>
|
|
</Box>
|
|
{relayMap && (
|
|
<Box className={styles.relaysContainer}>
|
|
{Object.keys(relayMap).map((relay, i) => (
|
|
<Box className={styles.relay} key={`relay_${i}`}>
|
|
<List>
|
|
<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} />
|
|
|
|
<Box
|
|
className={styles.leaveRelayContainer}
|
|
onClick={() => handleLeaveRelay(relay)}
|
|
>
|
|
<LogoutIcon />
|
|
<span>Leave</span>
|
|
</Box>
|
|
</ListItem>
|
|
<Divider className={styles.relayDivider} />
|
|
<ListItem>
|
|
<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
|
|
checked={relayMap[relay].write}
|
|
onChange={(event) => handleRelayWriteChange(relay, event)}
|
|
/>
|
|
</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>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
)
|
|
}
|