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
+
{
- 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)
+}