From 8790a943c3f424171325df4dcc7853f90003903d Mon Sep 17 00:00:00 2001 From: Yury Date: Fri, 24 May 2024 15:39:37 +0300 Subject: [PATCH] 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()