feat: add the ability to create and sign while user is offline #85
@ -1,4 +1,4 @@
|
||||
name: Release
|
||||
name: Release to Production
|
||||
on:
|
||||
push:
|
||||
branches:
|
32
.gitea/workflows/release-staging.yaml
Normal file
32
.gitea/workflows/release-staging.yaml
Normal file
@ -0,0 +1,32 @@
|
||||
name: Release to Staging
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- staging
|
||||
|
||||
jobs:
|
||||
build_and_release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Create .env File
|
||||
run: echo "VITE_MOST_POPULAR_RELAYS=${{ vars.VITE_MOST_POPULAR_RELAYS }}" > .env
|
||||
|
||||
- name: Create Build
|
||||
run: npm run build
|
||||
|
||||
- name: Release Build
|
||||
run: |
|
||||
npm -g install cloudron-surfer
|
||||
surfer put --token ${{ secrets.STAGING_CLOUDRON_SURFER_TOKEN }} --server staging.sigit.io dist/* /
|
@ -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
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setAnchorElUser(null)
|
||||
|
||||
navigate(appPrivateRoutes.relays)
|
||||
}}
|
||||
sx={{
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
Relays
|
||||
</MenuItem>
|
||||
<Link
|
||||
to={appPublicRoutes.help}
|
||||
target="_blank"
|
||||
|
@ -1,13 +1,18 @@
|
||||
import { EventTemplate } from 'nostr-tools'
|
||||
import { MetadataController, NostrController } from '.'
|
||||
import { setAuthState, setMetadataEvent } from '../store/actions'
|
||||
import {
|
||||
setAuthState,
|
||||
setMetadataEvent,
|
||||
setRelayMapAction
|
||||
} from '../store/actions'
|
||||
import store from '../store/store'
|
||||
import {
|
||||
base64DecodeAuthToken,
|
||||
base64EncodeSignedEvent,
|
||||
getAuthToken,
|
||||
getVisitedLink,
|
||||
saveAuthToken
|
||||
saveAuthToken,
|
||||
compareObjects
|
||||
} from '../utils'
|
||||
import { appPrivateRoutes } from '../routes'
|
||||
import { SignedEvent } from '../types'
|
||||
@ -30,7 +35,7 @@ export class AuthController {
|
||||
* @returns url to redirect if authentication successfull
|
||||
* or error if otherwise
|
||||
*/
|
||||
async authenticateAndFindMetadata(pubkey: string) {
|
||||
async authAndGetMetadataAndRelaysMap(pubkey: string) {
|
||||
const emptyMetadata = this.metadataController.getEmptyMetadataEvent()
|
||||
|
||||
this.metadataController
|
||||
@ -69,6 +74,22 @@ export class AuthController {
|
||||
})
|
||||
)
|
||||
|
||||
const relayMap = await this.nostrController.getRelayMap(pubkey)
|
||||
|
||||
if (Object.keys(relayMap).length < 1) {
|
||||
// Navigate user to relays page if relay map is empty
|
||||
return Promise.resolve(appPrivateRoutes.relays)
|
||||
}
|
||||
|
||||
if (store.getState().auth?.loggedIn) {
|
||||
if (!compareObjects(store.getState().relays?.map, relayMap.map))
|
||||
store.dispatch(setRelayMapAction(relayMap.map))
|
||||
}
|
||||
|
||||
const currentLocation = window.location.hash.replace('#', '')
|
||||
|
||||
if (!Object.values(appPrivateRoutes).includes(currentLocation)) {
|
||||
// User did change the location to one of the private routes
|
||||
const visitedLink = getVisitedLink()
|
||||
|
||||
if (visitedLink) {
|
||||
@ -80,6 +101,7 @@ export class AuthController {
|
||||
return Promise.resolve(appPrivateRoutes.homePage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkSession() {
|
||||
const savedAuthToken = getAuthToken()
|
||||
|
@ -58,12 +58,13 @@ export class MetadataController {
|
||||
return metadataEvent
|
||||
}
|
||||
|
||||
const mostPopularRelays = import.meta.env.VITE_MOST_POPULAR_RELAYS
|
||||
const hardcodedPopularRelays = (mostPopularRelays || '').split(' ')
|
||||
const relays = [...hardcodedPopularRelays]
|
||||
const mostPopularRelays = await this.nostrController.getMostPopularRelays()
|
||||
|
||||
const events = await pool.querySync(relays, eventFilter).catch((err) => {
|
||||
const events = await pool
|
||||
.querySync(mostPopularRelays, eventFilter)
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
@ -96,11 +97,12 @@ 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) => {
|
||||
relayEvent = await pool
|
||||
.get(mostPopularRelays, eventFilter)
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
return null
|
||||
})
|
||||
|
@ -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<string> => {
|
||||
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<string[]> => {
|
||||
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<RelayStats>(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<string> => {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
1
src/hooks/index.ts
Normal file
1
src/hooks/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './store'
|
6
src/hooks/store.ts
Normal file
6
src/hooks/store.ts
Normal file
@ -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<Dispatch>()
|
||||
export const useAppSelector = useSelector.withTypes<RootState>()
|
@ -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)
|
||||
)
|
||||
|
@ -81,9 +81,9 @@ export const Login = () => {
|
||||
|
||||
setLoadingSpinnerDesc('Authenticating and finding metadata')
|
||||
const redirectPath =
|
||||
await authController.authenticateAndFindMetadata(pubkey)
|
||||
await authController.authAndGetMetadataAndRelaysMap(pubkey)
|
||||
|
||||
navigateAfterLogin(redirectPath)
|
||||
if (redirectPath) navigateAfterLogin(redirectPath)
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error('Error capturing public key from nostr extension: ' + err)
|
||||
@ -135,7 +135,7 @@ export const Login = () => {
|
||||
setLoadingSpinnerDesc('Authenticating and finding metadata')
|
||||
|
||||
const redirectPath = await authController
|
||||
.authenticateAndFindMetadata(publickey)
|
||||
.authAndGetMetadataAndRelaysMap(publickey)
|
||||
.catch((err) => {
|
||||
toast.error('Error occurred in authentication: ' + err)
|
||||
return null
|
||||
@ -230,7 +230,7 @@ export const Login = () => {
|
||||
setLoadingSpinnerDesc('Authenticating and finding metadata')
|
||||
|
||||
const redirectPath = await authController
|
||||
.authenticateAndFindMetadata(pubkey!)
|
||||
.authAndGetMetadataAndRelaysMap(pubkey!)
|
||||
.catch((err) => {
|
||||
toast.error('Error occurred in authentication: ' + err)
|
||||
return null
|
||||
@ -290,7 +290,7 @@ export const Login = () => {
|
||||
setLoadingSpinnerDesc('Authenticating and finding metadata')
|
||||
|
||||
const redirectPath = await authController
|
||||
.authenticateAndFindMetadata(pubkey!)
|
||||
.authAndGetMetadataAndRelaysMap(pubkey!)
|
||||
.catch((err) => {
|
||||
toast.error('Error occurred in authentication: ' + err)
|
||||
return null
|
||||
|
518
src/pages/relays/index.tsx
Normal file
518
src/pages/relays/index.tsx
Normal file
@ -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<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>
|
||||
)
|
||||
}
|
107
src/pages/relays/style.module.scss
Normal file
107
src/pages/relays/style.module.scss
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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: <ProfileSettingsPage />
|
||||
},
|
||||
{
|
||||
path: appPrivateRoutes.relays,
|
||||
element: <RelaysPage />
|
||||
}
|
||||
]
|
||||
|
@ -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'
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
39
src/store/relays/action.ts
Normal file
39
src/store/relays/action.ts
Normal file
@ -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
|
||||
})
|
46
src/store/relays/reducer.ts
Normal file
46
src/store/relays/reducer.ts
Normal file
@ -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
|
43
src/store/relays/types.ts
Normal file
43
src/store/relays/types.ts
Normal file
@ -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
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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<typeof store.getState>
|
||||
|
@ -2,3 +2,4 @@ export * from './core'
|
||||
export * from './nostr'
|
||||
export * from './profile'
|
||||
export * from './zip'
|
||||
export * from './relay'
|
||||
|
@ -8,11 +8,6 @@ export interface SignedEvent {
|
||||
sig: string
|
||||
}
|
||||
|
||||
export interface RelaySet {
|
||||
read: string[]
|
||||
write: string[]
|
||||
}
|
||||
|
||||
export interface NostrJoiningBlock {
|
||||
block: number
|
||||
encodedEventPointer: string
|
||||
|
232
src/types/relay.ts
Normal file
232
src/types/relay.ts
Normal file
@ -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
|
||||
}
|
@ -5,3 +5,4 @@ export * from './misc'
|
||||
export * from './nostr'
|
||||
export * from './string'
|
||||
export * from './zip'
|
||||
export * from './utils'
|
||||
|
@ -85,3 +85,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()
|
||||
|
13
src/utils/utils.ts
Normal file
13
src/utils/utils.ts
Normal file
@ -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)
|
||||
}
|
Loading…
Reference in New Issue
Block a user