From 3c061d5920e2d518b6a837a61e151cc1586b88b7 Mon Sep 17 00:00:00 2001 From: daniyal Date: Fri, 6 Dec 2024 14:16:46 +0500 Subject: [PATCH] feat: added ndkContext and used it in relays page --- package-lock.json | 102 +++++++++--- package.json | 4 +- src/contexts/NDKContext.tsx | 243 ++++++++++++++++++++++++++++ src/hooks/index.ts | 2 + src/hooks/useDvm.ts | 98 +++++++++++ src/hooks/useNDKContext.ts | 13 ++ src/main.tsx | 5 +- src/pages/settings/relays/index.tsx | 138 +++++++++++++--- src/types/relay.ts | 6 + src/utils/nostr.ts | 22 +++ src/utils/relays.ts | 21 +-- 11 files changed, 590 insertions(+), 64 deletions(-) create mode 100644 src/contexts/NDKContext.tsx create mode 100644 src/hooks/useDvm.ts create mode 100644 src/hooks/useNDKContext.ts diff --git a/package-lock.json b/package-lock.json index a03e759..21aadbc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,12 +20,14 @@ "@mui/lab": "5.0.0-alpha.166", "@mui/material": "5.15.11", "@noble/hashes": "^1.4.0", - "@nostr-dev-kit/ndk": "2.5.0", + "@nostr-dev-kit/ndk": "2.10.0", + "@nostr-dev-kit/ndk-cache-dexie": "2.5.1", "@pdf-lib/fontkit": "^1.1.1", "@reduxjs/toolkit": "2.2.1", "axios": "^1.7.4", "crypto-hash": "3.0.0", "crypto-js": "^4.2.0", + "dexie": "4.0.8", "dnd-core": "16.0.1", "file-saver": "2.0.5", "idb": "8.0.0", @@ -1710,65 +1712,79 @@ } }, "node_modules/@nostr-dev-kit/ndk": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.5.0.tgz", - "integrity": "sha512-A2nRgjjLScDhGZGPWx8xUIJM66dJWScdWQoCn/tI1Gtwpple+C2Jp7C9t3mb0oF3bwd2nsV6qwS//wdrH8QvYQ==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.10.0.tgz", + "integrity": "sha512-TqCAAo6ylORraAXrzRkCGFN2xTMiFbdER8Y8CtUT0HwOpFG/Wn+PBNeDeDmqkl/6LaPdeyXmVwCWj2KcUjIwYA==", "dependencies": { + "@noble/curves": "^1.4.0", "@noble/hashes": "^1.3.1", "@noble/secp256k1": "^2.0.0", "@scure/base": "^1.1.1", "debug": "^4.3.4", "light-bolt11-decoder": "^3.0.0", "node-fetch": "^3.3.1", - "nostr-tools": "^1.15.0", + "nostr-tools": "^2.7.1", "tseep": "^1.1.1", "typescript-lru-cache": "^2.0.0", "utf8-buffer": "^1.0.0", "websocket-polyfill": "^0.0.3" + }, + "engines": { + "node": ">=16" } }, - "node_modules/@nostr-dev-kit/ndk/node_modules/@noble/ciphers": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.2.0.tgz", - "integrity": "sha512-6YBxJDAapHSdd3bLDv6x2wRPwq4QFMUaB3HvljNBUTThDd12eSm7/3F+2lnfzx2jvM+S6Nsy0jEt9QbPqSwqRw==", - "funding": { - "url": "https://paulmillr.com/funding/" + "node_modules/@nostr-dev-kit/ndk-cache-dexie": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk-cache-dexie/-/ndk-cache-dexie-2.5.1.tgz", + "integrity": "sha512-tUwEy68bd9GL5JVuZIjcpdwuDEBnaXen3WJ64/GRDtbyE1RB01Y6hHC7IQC9bcQ6SC7XBGyPd+2nuTyR7+Mffg==", + "dependencies": { + "@nostr-dev-kit/ndk": "2.10.0", + "debug": "^4.3.4", + "dexie": "^4.0.2", + "nostr-tools": "^2.4.0", + "typescript-lru-cache": "^2.0.0" } }, "node_modules/@nostr-dev-kit/ndk/node_modules/@noble/curves": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz", - "integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.7.0.tgz", + "integrity": "sha512-UTMhXK9SeDhFJVrHeUJ5uZlI6ajXg10O6Ddocf9S6GjbSBVZsJo88HzKwXznNfGpMTRDyJkqMjNDPYgf0qFWnw==", "dependencies": { - "@noble/hashes": "1.3.1" + "@noble/hashes": "1.6.0" + }, + "engines": { + "node": "^14.21.3 || >=16" }, "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/@nostr-dev-kit/ndk/node_modules/@noble/hashes": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz", - "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.6.0.tgz", + "integrity": "sha512-YUULf0Uk4/mAA89w+k3+yUYh6NrEvxZa5T6SY3wlMvE2chHkxFUUIDI8/XW1QSC357iA5pSnqt7XEhvFOqmDyQ==", "engines": { - "node": ">= 16" + "node": "^14.21.3 || >=16" }, "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/@nostr-dev-kit/ndk/node_modules/nostr-tools": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-1.17.0.tgz", - "integrity": "sha512-LZmR8GEWKZeElbFV5Xte75dOeE9EFUW/QLI1Ncn3JKn0kFddDKEfBbFN8Mu4TMs+L4HR/WTPha2l+PPuRnJcMw==", + "version": "2.10.4", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.10.4.tgz", + "integrity": "sha512-biU7sk+jxHgVASfobg2T5ttxOGGSt69wEVBC51sHHOEaKAAdzHBLV/I2l9Rf61UzClhliZwNouYhqIso4a3HYg==", "dependencies": { - "@noble/ciphers": "0.2.0", - "@noble/curves": "1.1.0", + "@noble/ciphers": "^0.5.1", + "@noble/curves": "1.2.0", "@noble/hashes": "1.3.1", "@scure/base": "1.1.1", "@scure/bip32": "1.3.1", "@scure/bip39": "1.2.1" }, + "optionalDependencies": { + "nostr-wasm": "0.1.0" + }, "peerDependencies": { "typescript": ">=5.0.0" }, @@ -1778,6 +1794,39 @@ } } }, + "node_modules/@nostr-dev-kit/ndk/node_modules/nostr-tools/node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nostr-dev-kit/ndk/node_modules/nostr-tools/node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nostr-dev-kit/ndk/node_modules/nostr-tools/node_modules/@noble/hashes": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz", + "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@pdf-lib/fontkit": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@pdf-lib/fontkit/-/fontkit-1.1.1.tgz", @@ -3850,6 +3899,11 @@ "node": ">=8" } }, + "node_modules/dexie": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.0.8.tgz", + "integrity": "sha512-1G6cJevS17KMDK847V3OHvK2zei899GwpDiqfEXHP1ASvme6eWJmAp9AU4s1son2TeGkWmC0g3y8ezOBPnalgQ==" + }, "node_modules/dezalgo": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", diff --git a/package.json b/package.json index ab49da0..983145b 100644 --- a/package.json +++ b/package.json @@ -30,12 +30,14 @@ "@mui/lab": "5.0.0-alpha.166", "@mui/material": "5.15.11", "@noble/hashes": "^1.4.0", - "@nostr-dev-kit/ndk": "2.5.0", + "@nostr-dev-kit/ndk": "2.10.0", + "@nostr-dev-kit/ndk-cache-dexie": "2.5.1", "@pdf-lib/fontkit": "^1.1.1", "@reduxjs/toolkit": "2.2.1", "axios": "^1.7.4", "crypto-hash": "3.0.0", "crypto-js": "^4.2.0", + "dexie": "4.0.8", "dnd-core": "16.0.1", "file-saver": "2.0.5", "idb": "8.0.0", diff --git a/src/contexts/NDKContext.tsx b/src/contexts/NDKContext.tsx new file mode 100644 index 0000000..edbb28a --- /dev/null +++ b/src/contexts/NDKContext.tsx @@ -0,0 +1,243 @@ +import NDK, { + getRelayListForUser, + NDKEvent, + NDKFilter, + NDKRelaySet, + NDKSubscriptionCacheUsage, + NDKUser, + NDKUserProfile +} from '@nostr-dev-kit/ndk' + +import NDKCacheAdapterDexie from '@nostr-dev-kit/ndk-cache-dexie' + +import { Dexie } from 'dexie' +import { createContext, ReactNode, useEffect, useMemo } from 'react' +import { toast } from 'react-toastify' +import { UserRelaysType } from '../types' +import { + DEFAULT_LOOK_UP_RELAY_LIST, + hexToNpub, + orderEventsChronologically, + timeout +} from '../utils' + +export interface NDKContextType { + ndk: NDK + fetchEvents: (filter: NDKFilter) => Promise + fetchEvent: (filter: NDKFilter) => Promise + fetchEventsFromUserRelays: ( + filter: NDKFilter | NDKFilter[], + hexKey: string, + userRelaysType: UserRelaysType + ) => Promise + fetchEventFromUserRelays: ( + filter: NDKFilter | NDKFilter[], + hexKey: string, + userRelaysType: UserRelaysType + ) => Promise + findMetadata: (pubkey: string) => Promise + publish: (event: NDKEvent, explicitRelayUrls?: string[]) => Promise +} + +// Create the context with an initial value of `null` +export const NDKContext = createContext(null) + +// Create a provider component to wrap around parts of your app +export const NDKContextProvider = ({ children }: { children: ReactNode }) => { + useEffect(() => { + window.onunhandledrejection = async (event: PromiseRejectionEvent) => { + event.preventDefault() + if (event.reason?.name === Dexie.errnames.DatabaseClosed) { + console.log( + 'Could not open Dexie DB, probably version change. Deleting old DB and reloading...' + ) + await Dexie.delete('degmod-db') + // Must reload to open a brand new DB + window.location.reload() + } + } + }, []) + + const ndk = useMemo(() => { + localStorage.setItem('debug', '*') + const dexieAdapter = new NDKCacheAdapterDexie({ dbName: 'degmod-db' }) + dexieAdapter.locking = true + const ndk = new NDK({ + enableOutboxModel: true, + autoConnectUserRelays: true, + autoFetchUserMutelist: true, + explicitRelayUrls: [...DEFAULT_LOOK_UP_RELAY_LIST], + cacheAdapter: dexieAdapter + }) + ndk.connect() + + return ndk + }, []) + + /** + * Asynchronously retrieves multiple event based on a provided filter. + * + * @param filter - The filter criteria to find the event. + * @returns Returns a promise that resolves to the found event or null if not found. + */ + const fetchEvents = async (filter: NDKFilter): Promise => { + return ndk + .fetchEvents(filter, { + closeOnEose: true, + cacheUsage: NDKSubscriptionCacheUsage.PARALLEL + }) + .then((ndkEventSet) => { + const ndkEvents = Array.from(ndkEventSet) + return orderEventsChronologically(ndkEvents) + }) + .catch((err) => { + // Log the error and show a notification if fetching fails + console.error('An error occurred in fetching events', err) + toast.error('An error occurred in fetching events') // Show error notification + return [] // Return an empty array in case of an error + }) + } + + /** + * Asynchronously retrieves an event based on a provided filter. + * + * @param filter - The filter criteria to find the event. + * @returns Returns a promise that resolves to the found event or null if not found. + */ + const fetchEvent = async (filter: NDKFilter) => { + const events = await fetchEvents(filter) + if (events.length === 0) return null + return events[0] + } + + /** + * Asynchronously retrieves multiple events from the user's relays based on a specified filter. + * The function first retrieves the user's relays, and then fetches the events using the provided filter. + * + * @param filter - The event filter to use when fetching the event (e.g., kinds, authors). + * @param hexKey - The hexadecimal representation of the user's public key. + * @param userRelaysType - The type of relays to search (e.g., write, read). + * @returns A promise that resolves with an array of events. + */ + const fetchEventsFromUserRelays = async ( + filter: NDKFilter | NDKFilter[], + hexKey: string, + userRelaysType: UserRelaysType + ): Promise => { + // Find the user's relays (10s timeout). + const relayUrls = await Promise.race([ + getRelayListForUser(hexKey, ndk), + timeout(3000) + ]) + .then((ndkRelayList) => { + if (ndkRelayList) return ndkRelayList[userRelaysType] + return [] // Return an empty array if ndkRelayList is undefined + }) + .catch((err) => { + console.error( + `An error occurred in fetching user's (${hexKey}) ${userRelaysType}`, + err + ) + return [] as string[] + }) + + return ndk + .fetchEvents( + filter, + { closeOnEose: true, cacheUsage: NDKSubscriptionCacheUsage.PARALLEL }, + relayUrls.length + ? NDKRelaySet.fromRelayUrls(relayUrls, ndk, true) + : undefined + ) + .then((ndkEventSet) => { + const ndkEvents = Array.from(ndkEventSet) + return orderEventsChronologically(ndkEvents) + }) + .catch((err) => { + // Log the error and show a notification if fetching fails + console.error('An error occurred in fetching events', err) + toast.error('An error occurred in fetching events') // Show error notification + return [] // Return an empty array in case of an error + }) + } + + /** + * Fetches an event from the user's relays based on a specified filter. + * The function first retrieves the user's relays, and then fetches the event using the provided filter. + * + * @param filter - The event filter to use when fetching the event (e.g., kinds, authors). + * @param hexKey - The hexadecimal representation of the user's public key. + * @param userRelaysType - The type of relays to search (e.g., write, read). + * @returns A promise that resolves to the fetched event or null if the operation fails. + */ + const fetchEventFromUserRelays = async ( + filter: NDKFilter | NDKFilter[], + hexKey: string, + userRelaysType: UserRelaysType + ) => { + const events = await fetchEventsFromUserRelays( + filter, + hexKey, + userRelaysType + ) + if (events.length === 0) return null + return events[0] + } + + /** + * Finds metadata for a given pubkey. + * + * @param hexKey - The pubkey to search for metadata. + * @returns A promise that resolves to the metadata event. + */ + const findMetadata = async ( + pubkey: string + ): Promise => { + const npub = hexToNpub(pubkey) + + const user = new NDKUser({ npub }) + user.ndk = ndk + + return await user.fetchProfile() + } + + const publish = async ( + event: NDKEvent, + explicitRelayUrls?: string[] + ): Promise => { + if (!event.sig) throw new Error('Before publishing first sign the event!') + + let ndkRelaySet: NDKRelaySet | undefined + + if (explicitRelayUrls && explicitRelayUrls.length > 0) { + ndkRelaySet = NDKRelaySet.fromRelayUrls(explicitRelayUrls, ndk) + } + + return event + .publish(ndkRelaySet, 10000) + .then((res) => { + const relaysPublishedOn = Array.from(res) + return relaysPublishedOn.map((relay) => relay.url) + }) + .catch((err) => { + console.error(`An error occurred in publishing event`, err) + return [] + }) + } + + return ( + + {children} + + ) +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index e7ec305..91dc278 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,2 +1,4 @@ export * from './store' export * from './useDidMount' +export * from './useDvm' +export * from './useNDKContext' diff --git a/src/hooks/useDvm.ts b/src/hooks/useDvm.ts new file mode 100644 index 0000000..089d16d --- /dev/null +++ b/src/hooks/useDvm.ts @@ -0,0 +1,98 @@ +import { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk' +import { EventTemplate } from 'nostr-tools' +import { NostrController } from '../controllers' +import { setRelayInfoAction } from '../store/actions' +import { RelayInfoObject } from '../types' +import { compareObjects, unixNow } from '../utils' +import { useAppDispatch, useAppSelector } from './store' +import { useNDKContext } from './useNDKContext' + +export const useDvm = () => { + const dvmRelays = [ + 'wss://relay.damus.io', + 'wss://relay.primal.net', + 'wss://relayable.org' + ] + + const relayInfo = useAppSelector((state) => state.relays.info) + + const { ndk, publish } = useNDKContext() + const dispatch = useAppDispatch() + + /** + * Sets information about relays into relays.info app state. + * @param relayURIs - relay URIs to get information about + */ + 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) + + // publish job request + const ndkEvent = new NDKEvent(ndk, jobSignedEvent) + await publish(ndkEvent, dvmRelays) + + 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)) + }) + } + + // filter for getting DVM job's result + const sub = ndk.subscribe({ + kinds: [68002 as number], + '#e': [jobSignedEvent.id], + '#p': [jobSignedEvent.pubkey] + }) + + // asynchronously get relay info 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 newRelaysInfo: RelayInfoObject + + try { + newRelaysInfo = JSON.parse(dvmJobResult) + } catch (error) { + return Promise.reject(`Invalid relay(s) information.`) + } + + if (newRelaysInfo && !compareObjects(relayInfo, newRelaysInfo)) { + dispatch(setRelayInfoAction(newRelaysInfo)) + } + } + + return { getRelayInfo } +} diff --git a/src/hooks/useNDKContext.ts b/src/hooks/useNDKContext.ts new file mode 100644 index 0000000..9d502fc --- /dev/null +++ b/src/hooks/useNDKContext.ts @@ -0,0 +1,13 @@ +import { NDKContext, NDKContextType } from '../contexts/NDKContext' +import { useContext } from 'react' + +export const useNDKContext = () => { + const ndkContext = useContext(NDKContext) + + if (!ndkContext) + throw new Error( + 'NDKContext should not be used in out component tree hierarchy' + ) + + return { ...ndkContext } as NDKContextType +} diff --git a/src/main.tsx b/src/main.tsx index a8b1898..05ea4ed 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -11,6 +11,7 @@ import './index.css' import store from './store/store.ts' import { theme } from './theme' import { saveState } from './utils' +import { NDKContextProvider } from './contexts/NDKContext' store.subscribe( _.throttle(() => { @@ -28,7 +29,9 @@ ReactDOM.createRoot(document.getElementById('root')!).render( - + + + diff --git a/src/pages/settings/relays/index.tsx b/src/pages/settings/relays/index.tsx index a1f5223..ba776fb 100644 --- a/src/pages/settings/relays/index.tsx +++ b/src/pages/settings/relays/index.tsx @@ -13,26 +13,45 @@ import Switch from '@mui/material/Switch' import { useEffect, useState } from 'react' import { toast } from 'react-toastify' import { Container } from '../../../components/Container' -import { relayController } from '../../../controllers' -import { useAppDispatch, useAppSelector, useDidMount } from '../../../hooks' +import { + useAppDispatch, + useAppSelector, + useDidMount, + useDvm, + useNDKContext +} from '../../../hooks' import { setRelayMapAction } from '../../../store/actions' -import { RelayConnectionState, RelayFee, RelayInfo } from '../../../types' +import { + RelayConnectionState, + RelayFee, + RelayInfo, + RelayMap +} from '../../../types' import { capitalizeFirstLetter, compareObjects, - getRelayInfo, - getRelayMap, hexToNpub, + normalizeWebSocketURL, publishRelayMap, - shorten + shorten, + timeout } from '../../../utils' import styles from './style.module.scss' import { Footer } from '../../../components/Footer/Footer' +import { + getRelayListForUser, + NDKRelayList, + NDKRelayStatus +} from '@nostr-dev-kit/ndk' export const RelaysPage = () => { + const dispatch = useAppDispatch() + const { ndk, publish } = useNDKContext() + const { getRelayInfo } = useDvm() + const usersPubkey = useAppSelector((state) => state.auth?.usersPubkey) - const dispatch = useAppDispatch() + const [ndkRelayList, setNDKRelayList] = useState(null) const [newRelayURI, setNewRelayURI] = useState() const [newRelayURIerror, setNewRelayURIerror] = useState() @@ -42,22 +61,74 @@ export const RelaysPage = () => { const webSocketPrefix = 'wss://' - useDidMount(() => { + // fetch relay list from relays + useEffect(() => { if (usersPubkey) { - getRelayMap(usersPubkey).then((newRelayMap) => { - if (!compareObjects(relayMap, newRelayMap.map)) { - dispatch(setRelayMapAction(newRelayMap.map)) + Promise.race([getRelayListForUser(usersPubkey, ndk), timeout(10000)]) + .then((res) => { + setNDKRelayList(res) + }) + .catch((err) => { + toast.error( + `An error occurred in fetching user relay list: ${ + err.message || err + }` + ) + setNDKRelayList(new NDKRelayList(ndk)) + }) + } + }, [usersPubkey, ndk]) + + // construct the RelayMap from newly received NDKRelayList event + // and compare it with existing relay map in redux store + // if there are any differences then update the redux store with + // new relay map + useEffect(() => { + if (ndkRelayList) { + const newRelayMap: RelayMap = {} + + ndkRelayList.readRelayUrls.forEach((relayUrl) => { + const normalizedUrl = normalizeWebSocketURL(relayUrl) + + newRelayMap[normalizedUrl] = { + read: true, + write: false } }) + + ndkRelayList.writeRelayUrls.forEach((relayUrl) => { + const normalizedUrl = normalizeWebSocketURL(relayUrl) + + const existing = newRelayMap[normalizedUrl] + if (existing) { + existing.write = true + } else { + newRelayMap[normalizedUrl] = { + read: false, + write: true + } + } + }) + + if (!compareObjects(relayMap, newRelayMap)) { + dispatch(setRelayMapAction(newRelayMap)) + } } - }) + + // we want to run this effect only when ndkRelayList is changed + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ndkRelayList]) useEffect(() => { + if (!relayMap) return + // Display notification if an empty relay map has been received - if (relayMap && Object.keys(relayMap).length === 0) { + if (Object.keys(relayMap).length === 0) { relayRequirementWarning() + } else { + getRelayInfo(Object.keys(relayMap)) } - }, [relayMap]) + }, [relayMap, getRelayInfo]) const relayRequirementWarning = () => toast.warning('At least one write relay is needed for SIGit to work.') @@ -85,7 +156,8 @@ export const RelaysPage = () => { const relayMapPublishingRes = await publishRelayMap( relayMapCopy, usersPubkey, - [relay] + ndk, + publish ).catch((err) => handlePublishRelayMapError(err)) if (relayMapPublishingRes) { @@ -132,7 +204,9 @@ export const RelaysPage = () => { // Publish updated relay map const relayMapPublishingRes = await publishRelayMap( relayMapCopy, - usersPubkey + usersPubkey, + ndk, + publish ).catch((err) => handlePublishRelayMapError(err)) if (relayMapPublishingRes) { @@ -161,9 +235,10 @@ export const RelaysPage = () => { ) } } else if (relayURI && usersPubkey) { - const relay = await relayController.connectRelay(relayURI) + const ndkRelay = ndk.pool.getRelay(relayURI) + await ndkRelay.connect(5000) - if (relay && relay.connected) { + if (ndkRelay.status >= NDKRelayStatus.CONNECTED) { const relayMapCopy = JSON.parse(JSON.stringify(relayMap)) relayMapCopy[relayURI] = { write: true, read: true } @@ -171,7 +246,9 @@ export const RelaysPage = () => { // Publish updated relay map const relayMapPublishingRes = await publishRelayMap( relayMapCopy, - usersPubkey + usersPubkey, + ndk, + publish ).catch((err) => handlePublishRelayMapError(err)) if (relayMapPublishingRes) { @@ -256,19 +333,36 @@ const RelayItem = ({ handleLeaveRelay, handleRelayWriteChange }: RelayItemProp) => { + const { ndk } = useNDKContext() + const [relayConnectionStatus, setRelayConnectionStatus] = useState() const [displayRelayInfo, setDisplayRelayInfo] = useState(false) useDidMount(() => { - relayController.connectRelay(relayURI).then((relay) => { - if (relay && relay.connected) { + const ndkPool = ndk.pool + + ndkPool.on('relay:connect', (relay) => { + if (relay.url === relayURI) { setRelayConnectionStatus(RelayConnectionState.Connected) - } else { + } + }) + + ndkPool.on('relay:disconnect', (relay) => { + if (relay.url === relayURI) { setRelayConnectionStatus(RelayConnectionState.NotConnected) } }) + + const relay = ndkPool.getRelay(relayURI) + if (relay) { + setRelayConnectionStatus( + relay.status >= NDKRelayStatus.CONNECTED + ? RelayConnectionState.Connected + : RelayConnectionState.NotConnected + ) + } }) return ( diff --git a/src/types/relay.ts b/src/types/relay.ts index dd41095..401e5ee 100644 --- a/src/types/relay.ts +++ b/src/types/relay.ts @@ -1,3 +1,9 @@ +export enum UserRelaysType { + Read = 'readRelayUrls', + Write = 'writeRelayUrls', + Both = 'bothRelayUrls' +} + export interface RelaySet { read: string[] write: string[] diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index ec8c97e..e7dbcbe 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -35,6 +35,7 @@ import { parseJson, removeLeadingSlash } from './string' import { timeout } from './utils' import { getHash } from './hash' import { SIGIT_BLOSSOM } from './const.ts' +import { NDKEvent } from '@nostr-dev-kit/ndk' /** * Generates a `d` tag for userAppData @@ -989,3 +990,24 @@ export const getProfileUsername = ( truncate(profile?.display_name || profile?.name || hexToNpub(npub), { length: 16 }) + +/** + * Orders an array of NDKEvent objects chronologically based on their `created_at` property. + * + * @param events - The array of NDKEvent objects to be sorted. + * @param reverse - Optional flag to reverse the sorting order. + * If true, sorts in ascending order (oldest first), otherwise sorts in descending order (newest first). + * + * @returns The sorted array of events. + */ +export function orderEventsChronologically( + events: NDKEvent[], + reverse: boolean = false +): NDKEvent[] { + events.sort((e1: NDKEvent, e2: NDKEvent) => { + if (reverse) return e1.created_at! - e2.created_at! + else return e2.created_at! - e1.created_at! + }) + + return events +} diff --git a/src/utils/relays.ts b/src/utils/relays.ts index bfef7aa..bcbbad9 100644 --- a/src/utils/relays.ts +++ b/src/utils/relays.ts @@ -10,6 +10,7 @@ import { ONE_WEEK_IN_MS, SIGIT_RELAY } from './const' +import NDK, { NDKEvent } from '@nostr-dev-kit/ndk' const READ_MARKER = 'read' const WRITE_MARKER = 'write' @@ -176,7 +177,8 @@ const getRelayMap = async ( const publishRelayMap = async ( relayMap: RelayMap, npub: string, - extraRelaysToPublish?: string[] + ndk: NDK, + publish: (event: NDKEvent) => Promise ): Promise => { const timestamp = unixNow() const relayURIs = Object.keys(relayMap) @@ -205,21 +207,8 @@ const publishRelayMap = async ( 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 = DEFAULT_LOOK_UP_RELAY_LIST - } - const publishResult = await relayController.publish( - signedEvent, - relaysToPublish - ) + const ndkEvent = new NDKEvent(ndk, signedEvent) + const publishResult = await publish(ndkEvent) if (publishResult && publishResult.length) { return Promise.resolve(