From 66c7182fa4e0ec1f18c8bea7e937dcd5accfd262 Mon Sep 17 00:00:00 2001 From: daniyal Date: Sun, 18 Aug 2024 22:48:48 +0500 Subject: [PATCH] chore(refactor): move dvm related function to dvm utils file and relay related to relays utils file --- src/controllers/AuthController.ts | 9 +- src/controllers/NostrController.ts | 345 +---------------- src/controllers/RelayController.ts | 63 ++-- src/hooks/index.ts | 1 + src/hooks/useDidMount.ts | 12 + src/pages/settings/relays/index.tsx | 564 ++++++++++++---------------- src/utils/dvm.ts | 97 ++++- src/utils/nostr.ts | 4 +- src/utils/relays.ts | 126 ++++++- 9 files changed, 517 insertions(+), 704 deletions(-) create mode 100644 src/hooks/useDidMount.ts diff --git a/src/controllers/AuthController.ts b/src/controllers/AuthController.ts index 33f5c82..09b20df 100644 --- a/src/controllers/AuthController.ts +++ b/src/controllers/AuthController.ts @@ -1,22 +1,23 @@ import { EventTemplate } from 'nostr-tools' import { MetadataController, NostrController } from '.' +import { appPrivateRoutes } from '../routes' import { setAuthState, setMetadataEvent, setRelayMapAction } from '../store/actions' import store from '../store/store' +import { SignedEvent } from '../types' import { base64DecodeAuthToken, base64EncodeSignedEvent, + compareObjects, getAuthToken, + getRelayMap, getVisitedLink, saveAuthToken, - compareObjects, unixNow } from '../utils' -import { appPrivateRoutes } from '../routes' -import { SignedEvent } from '../types' export class AuthController { private nostrController: NostrController @@ -75,7 +76,7 @@ export class AuthController { }) ) - const relayMap = await this.nostrController.getRelayMap(pubkey) + const relayMap = await getRelayMap(pubkey) if (Object.keys(relayMap).length < 1) { // Navigate user to relays page if relay map is empty diff --git a/src/controllers/NostrController.ts b/src/controllers/NostrController.ts index cf6c5d6..0547ffb 100644 --- a/src/controllers/NostrController.ts +++ b/src/controllers/NostrController.ts @@ -2,47 +2,24 @@ import NDK, { NDKEvent, NDKNip46Signer, NDKPrivateKeySigner, - NDKSubscription, NDKUser, NostrEvent } from '@nostr-dev-kit/ndk' import { Event, EventTemplate, - Filter, - Relay, - SimplePool, UnsignedEvent, finalizeEvent, - kinds, nip04, nip19, nip44 } from 'nostr-tools' import { EventEmitter } from 'tseep' -import { - setRelayConnectionStatusAction, - setRelayInfoAction, - updateNsecbunkerPubkey -} from '../store/actions' +import { updateNsecbunkerPubkey } from '../store/actions' import { AuthState, LoginMethods } from '../store/auth/types' import store from '../store/store' -import { - RelayConnectionState, - RelayConnectionStatus, - RelayInfoObject, - RelayMap, - SignedEvent -} from '../types' -import { - compareObjects, - getDefaultRelayMap, - getMostPopularRelays, - getNsecBunkerDelegatedKey, - unixNow, - verifySignedEvent -} from '../utils' -import { relayController } from './' +import { SignedEvent } from '../types' +import { getNsecBunkerDelegatedKey, verifySignedEvent } from '../utils' export class NostrController extends EventEmitter { private static instance: NostrController @@ -50,14 +27,13 @@ export class NostrController extends EventEmitter { private bunkerNDK: NDK | undefined private remoteSigner: NDKNip46Signer | undefined - private connectedRelays: Relay[] | undefined - private constructor() { super() } private getNostrObject = () => { // fix: this is not picking up type declaration from src/system/index.d.ts + // eslint-disable-next-line @typescript-eslint/no-explicit-any if (window.nostr) return window.nostr as any throw new Error( @@ -555,317 +531,4 @@ 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 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.resolve({ map: getDefaultRelayMap() }) - } - } - - /** - * 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 = unixNow() - 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 getMostPopularRelays() - } - - const publishResult = await relayController.publish( - 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.') - } - - /** - * 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: unixNow(), - 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 relayController.publish(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/controllers/RelayController.ts b/src/controllers/RelayController.ts index 1c13cae..4ab711c 100644 --- a/src/controllers/RelayController.ts +++ b/src/controllers/RelayController.ts @@ -1,4 +1,4 @@ -import { Filter, Relay, Event } from 'nostr-tools' +import { Event, Filter, Relay } from 'nostr-tools' import { normalizeWebSocketURL, timeout } from '../utils' import { SIGIT_RELAY } from '../utils/const' @@ -7,7 +7,7 @@ import { SIGIT_RELAY } from '../utils/const' */ export class RelayController { private static instance: RelayController - public connectedRelays: Relay[] = [] + public connectedRelays = new Map() private constructor() {} @@ -34,34 +34,44 @@ export class RelayController { * @param relayUrl - The URL of the relay server to connect to. * @returns A promise that resolves to the connected relay object if successful, or `null` if the connection fails. */ - public connectRelay = async (relayUrl: string) => { + public connectRelay = async (relayUrl: string): Promise => { // Check if a relay with the same URL is already connected - const relay = this.connectedRelays.find( - (relay) => - normalizeWebSocketURL(relay.url) === normalizeWebSocketURL(relayUrl) - ) + const normalizedWebSocketURL = normalizeWebSocketURL(relayUrl) + const relay = this.connectedRelays.get(normalizedWebSocketURL) - // If a matching relay is found, return it (skip connection) if (relay) { - return relay + // If a relay is found in connectedRelay map and is connected, just return it + if (relay.connected) return relay + + // If relay is found in connectedRelay map but not connected, + // remove it from map and call connectRelay method again + this.connectedRelays.delete(relayUrl) + + return this.connectRelay(relayUrl) } - try { - // Attempt to connect to the relay using the provided URL - const newRelay = await Relay.connect(relayUrl) + // Attempt to connect to the relay using the provided URL + const newRelay = await Relay.connect(relayUrl) + .then((relay) => { + if (relay.connected) { + // Add the newly connected relay to the connected relays map + this.connectedRelays.set(relayUrl, relay) - // Add the newly connected relay to the list of connected relays - this.connectedRelays.push(newRelay) + // Return the newly connected relay + return relay + } - // Return the newly connected relay - return newRelay - } catch (err) { - // Log an error message if the connection fails - console.error(`Relay connection failed: ${relayUrl}`, err) + return null + }) + .catch((err) => { + // Log an error message if the connection fails + console.error(`Relay connection failed: ${relayUrl}`, err) - // Return null to indicate connection failure - return null - } + // Return null to indicate connection failure + return null + }) + + return newRelay } /** @@ -109,6 +119,11 @@ export class RelayController { // Create a promise for each relay subscription const subPromises = relays.map((relay) => { return new Promise((resolve) => { + if (!relay.connected) { + console.log(`${relay.url} : Not connected!`, 'Skipping subscription') + return + } + // Subscribe to the relay with the specified filter const sub = relay.subscribe([filter], { // Handle incoming events @@ -274,11 +289,11 @@ export class RelayController { try { await Promise.race([ relay.publish(event), // Publish the event to the relay - timeout(30000) // Set a timeout to handle cases where publishing takes too long + timeout(20 * 1000) // Set a timeout to handle cases where publishing takes too long ]) publishedOnRelays.push(relay.url) // Add the relay URL to the list of successfully published relays } catch (err) { - console.error(`Failed to publish event on relay: ${relay}`, err) + console.error(`Failed to publish event on relay: ${relay.url}`, err) } }) diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 16c8633..e7ec305 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1 +1,2 @@ export * from './store' +export * from './useDidMount' diff --git a/src/hooks/useDidMount.ts b/src/hooks/useDidMount.ts new file mode 100644 index 0000000..5bac96a --- /dev/null +++ b/src/hooks/useDidMount.ts @@ -0,0 +1,12 @@ +import { useRef, useEffect } from 'react' + +export const useDidMount = (callback: () => void) => { + const didMount = useRef(false) + + useEffect(() => { + if (callback && !didMount.current) { + didMount.current = true + callback() + } + }) +} diff --git a/src/pages/settings/relays/index.tsx b/src/pages/settings/relays/index.tsx index 929a093..73e8c6f 100644 --- a/src/pages/settings/relays/index.tsx +++ b/src/pages/settings/relays/index.tsx @@ -12,138 +12,41 @@ import ListItemText from '@mui/material/ListItemText' import Switch from '@mui/material/Switch' import { useEffect, useState } from 'react' import { toast } from 'react-toastify' -import { NostrController } from '../../../controllers' -import { useAppDispatch, useAppSelector } from '../../../hooks' -import { - setRelayMapAction, - setRelayMapUpdatedAction -} from '../../../store/actions' -import { - RelayConnectionState, - RelayFee, - RelayInfoObject, - RelayMap -} from '../../../types' +import { Container } from '../../../components/Container' +import { relayController } from '../../../controllers' +import { useAppDispatch, useAppSelector, useDidMount } from '../../../hooks' +import { setRelayMapAction } from '../../../store/actions' +import { RelayConnectionState, RelayFee, RelayInfo } from '../../../types' import { capitalizeFirstLetter, - compareObjects, + getRelayInfo, + getRelayMap, hexToNpub, + publishRelayMap, shorten } from '../../../utils' import styles from './style.module.scss' -import { Container } from '../../../components/Container' 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 relayMap = useAppSelector((state) => state.relays?.map) + const relaysInfo = useAppSelector((state) => state.relays?.info) const webSocketPrefix = 'wss://' - // Update relay connection status - useEffect(() => { - if ( - !compareObjects(relaysConnectionStatus, relaysState?.connectionStatus) - ) { - setRelaysConnectionStatus(relaysState?.connectionStatus) + useDidMount(() => { + if (usersPubkey) { + getRelayMap(usersPubkey).then((newRelayMap) => { + dispatch(setRelayMapAction(newRelayMap.map)) + }) } - }, [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 !== undefined && - 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 @@ -175,24 +78,23 @@ export const RelaysPage = () => { if (usersPubkey) { // Publish updated relay map. - const relayMapPublishingRes = await nostrController - .publishRelayMap(relayMapCopy, usersPubkey, [relay]) - .catch((err) => handlePublishRelayMapError(err)) + const relayMapPublishingRes = await publishRelayMap( + relayMapCopy, + usersPubkey, + [relay] + ).catch((err) => handlePublishRelayMapError(err)) if (relayMapPublishingRes) { toast.success(relayMapPublishingRes) - setRelayMap(relayMapCopy) - dispatch(setRelayMapAction(relayMapCopy)) } } - - nostrController.disconnectFromRelays([relay]) } } } + // eslint-disable-next-line @typescript-eslint/no-explicit-any const handlePublishRelayMapError = (err: any) => { const errorPrefix = 'Error while publishing Relay Map' @@ -224,15 +126,14 @@ export const RelaysPage = () => { if (usersPubkey) { // Publish updated relay map - const relayMapPublishingRes = await nostrController - .publishRelayMap(relayMapCopy, usersPubkey) - .catch((err) => handlePublishRelayMapError(err)) + const relayMapPublishingRes = await publishRelayMap( + relayMapCopy, + usersPubkey + ).catch((err) => handlePublishRelayMapError(err)) if (relayMapPublishingRes) { toast.success(relayMapPublishingRes) - setRelayMap(relayMapCopy) - dispatch(setRelayMapAction(relayMapCopy)) } } @@ -256,29 +157,25 @@ export const RelaysPage = () => { ) } } else if (relayURI && usersPubkey) { - const connectionStatus = await nostrController.connectToRelays([relayURI]) + const relay = await relayController.connectRelay(relayURI) - if ( - connectionStatus && - connectionStatus[relayURI] && - connectionStatus[relayURI] === RelayConnectionState.Connected - ) { + if (relay && relay.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)) + const relayMapPublishingRes = await publishRelayMap( + relayMapCopy, + usersPubkey + ).catch((err) => handlePublishRelayMapError(err)) if (relayMapPublishingRes) { - setRelayMap(relayMapCopy) setNewRelayURI('') dispatch(setRelayMapAction(relayMapCopy)) - nostrController.getRelayInfo([relayURI]) + getRelayInfo([relayURI]) toast.success(relayMapPublishingRes) } @@ -292,29 +189,6 @@ export const RelaysPage = () => { } } - // 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 ( @@ -343,177 +217,211 @@ export const RelaysPage = () => { {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} - - ) - })} -
-
- - )} -
-
+ {Object.keys(relayMap).map((relay) => ( + ))}
)}
) } + +type RelayItemProp = { + relayURI: string + isWriteRelay: boolean + relayInfo?: RelayInfo + handleLeaveRelay: (relay: string) => void + handleRelayWriteChange: ( + relay: string, + event: React.ChangeEvent + ) => Promise +} + +const RelayItem = ({ + relayURI, + isWriteRelay, + relayInfo, + handleLeaveRelay, + handleRelayWriteChange +}: RelayItemProp) => { + const [relayConnectionStatus, setRelayConnectionStatus] = + useState() + + const [displayRelayInfo, setDisplayRelayInfo] = useState(false) + + useDidMount(() => { + relayController.connectRelay(relayURI).then((relay) => { + if (relay && relay.connected) { + setRelayConnectionStatus(RelayConnectionState.Connected) + } else { + setRelayConnectionStatus(RelayConnectionState.NotConnected) + } + }) + }) + + return ( + + + + + {relayInfo && + relayInfo.limitation && + relayInfo.limitation?.payment_required && ( + + setDisplayRelayInfo((prev) => !prev)} + /> + + )} + + + + handleLeaveRelay(relayURI)} + > + + Leave + + + + + setDisplayRelayInfo((prev) => !prev)} + className={styles.showInfo} + > + Show info{' '} + {displayRelayInfo ? ( + + ) : ( + + )} + + ) : ( + '' + ) + } + /> + handleRelayWriteChange(relayURI, event)} + /> + + {displayRelayInfo && ( + <> + + + + {relayInfo && + Object.keys(relayInfo).map((key: string) => { + const infoTitle = capitalizeFirstLetter( + key.replace('_', ' ') + ) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let infoValue = (relayInfo 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( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + hexToNpub((relayInfo as any)[key]) + ) + + toast.success('Copied to clipboard', { + autoClose: 1000, + hideProgressBar: true + }) + }} + /> + ) : null} + + ) + })} +
+
+ + )} +
+
+ ) +} diff --git a/src/utils/dvm.ts b/src/utils/dvm.ts index ba985ed..8995ae7 100644 --- a/src/utils/dvm.ts +++ b/src/utils/dvm.ts @@ -1,12 +1,14 @@ import { EventTemplate, Filter, kinds, nip19 } from 'nostr-tools' -import { queryNip05, unixNow } from '.' +import { compareObjects, queryNip05, unixNow } from '.' import { MetadataController, NostrController, relayController } from '../controllers' -import { NostrJoiningBlock } from '../types' +import { NostrJoiningBlock, RelayInfoObject } from '../types' import NDK, { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk' +import store from '../store/store' +import { setRelayInfoAction } from '../store/actions' export const getNostrJoiningBlockNumber = async ( hexKey: string @@ -133,3 +135,94 @@ export const getNostrJoiningBlockNumber = async ( return null } + +/** + * Sets information about relays into relays.info app state. + * @param relayURIs - relay URIs to get information about + */ +export const getRelayInfo = async (relayURIs: string[]) => { + // initialize job request + const jobEventTemplate: EventTemplate = { + content: '', + created_at: unixNow(), + kind: 68001, + tags: [ + ['i', `${JSON.stringify(relayURIs)}`], + ['j', 'relay-info'] + ] + } + + const nostrController = NostrController.getInstance() + + // sign job request event + const jobSignedEvent = await nostrController.signEvent(jobEventTemplate) + + const relays = [ + 'wss://relay.damus.io', + 'wss://relay.primal.net', + 'wss://relayable.org' + ] + + // publish job request + await relayController.publish(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)) + } +} diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index 48ce930..f3d6736 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -603,7 +603,7 @@ export const updateUsersAppData = async (meta: Meta) => { const publishResult = await Promise.race([ relayController.publish(signedEvent, writeRelays), - timeout(1000 * 30) + timeout(40 * 1000) ]).catch((err) => { console.log('err :>> ', err) if (err.message === 'Timeout') { @@ -936,7 +936,7 @@ export const sendNotification = async (receiver: string, meta: Meta) => { // Publish the notification event to the recipient's read relays await Promise.race([ relayController.publish(wrappedEvent, relaySet.read), - timeout(1000 * 30) + timeout(40 * 1000) ]).catch((err) => { // Log an error if publishing the notification event fails console.log( diff --git a/src/utils/relays.ts b/src/utils/relays.ts index 2d2d8cd..32bfdd4 100644 --- a/src/utils/relays.ts +++ b/src/utils/relays.ts @@ -1,12 +1,13 @@ import axios from 'axios' -import { Event, Filter } from 'nostr-tools' +import { Event, Filter, kinds, UnsignedEvent } from 'nostr-tools' import { RelayList } from 'nostr-tools/kinds' -import { relayController } from '../controllers/RelayController.ts' +import { getRelayInfo, unixNow } from '.' +import { NostrController, relayController } from '../controllers' import { localCache } from '../services' import { setMostPopularRelaysAction } from '../store/actions' import store from '../store/store' import { RelayMap, RelayReadStats, RelaySet, RelayStats } from '../types' -import { ONE_WEEK_IN_MS, SIGIT_RELAY } from './const.ts' +import { ONE_WEEK_IN_MS, SIGIT_RELAY } from './const' const READ_MARKER = 'read' const WRITE_MARKER = 'write' @@ -154,12 +155,131 @@ const getMostPopularRelays = async ( return apiTopRelays } +/** + * Provides relay map. + * @param npub - user's npub + * @returns - promise that resolves into relay map and a timestamp when it has been updated. + */ +const getRelayMap = async ( + npub: string +): Promise<{ map: RelayMap; mapUpdated?: number }> => { + const mostPopularRelays = await getMostPopularRelays() + + // 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 relayController + .fetchEvent(eventFilter, mostPopularRelays) + .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 + } + }) + + Object.keys(relaysMap).forEach((relayUrl) => { + relayController.connectRelay(relayUrl) + }) + + getRelayInfo(Object.keys(relaysMap)) + + return Promise.resolve({ map: relaysMap, mapUpdated: event.created_at }) + } else { + return Promise.resolve({ map: getDefaultRelayMap() }) + } +} + +/** + * 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. + */ +const publishRelayMap = async ( + relayMap: RelayMap, + npub: string, + extraRelaysToPublish?: string[] +): Promise => { + const timestamp = unixNow() + 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 nostrController = NostrController.getInstance() + const signedEvent = await nostrController.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 getMostPopularRelays() + } + + const publishResult = await relayController.publish( + 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.') +} + export { findRelayListAndUpdateCache, findRelayListInCache, getDefaultRelayMap, getDefaultRelaySet, getMostPopularRelays, + getRelayMap, + publishRelayMap, getUserRelaySet, isOlderThanOneWeek }