From 3c061d5920e2d518b6a837a61e151cc1586b88b7 Mon Sep 17 00:00:00 2001 From: daniyal Date: Fri, 6 Dec 2024 14:16:46 +0500 Subject: [PATCH 01/16] 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( -- 2.34.1 From 2248128001ef1de641fe4846f8968893b772f5d7 Mon Sep 17 00:00:00 2001 From: daniyal Date: Fri, 6 Dec 2024 21:12:40 +0500 Subject: [PATCH 02/16] chore(refactor): use NDKContext in profile page --- src/contexts/NDKContext.tsx | 4 +- src/pages/profile/index.tsx | 174 ++++++++-------------- src/pages/settings/profile/index.tsx | 208 ++++++++++++--------------- 3 files changed, 155 insertions(+), 231 deletions(-) diff --git a/src/contexts/NDKContext.tsx b/src/contexts/NDKContext.tsx index edbb28a..3075f5e 100644 --- a/src/contexts/NDKContext.tsx +++ b/src/contexts/NDKContext.tsx @@ -198,7 +198,9 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => { const user = new NDKUser({ npub }) user.ndk = ndk - return await user.fetchProfile() + return await user.fetchProfile({ + cacheUsage: NDKSubscriptionCacheUsage.PARALLEL + }) } const publish = async ( diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx index 7a2f720..e50bb30 100644 --- a/src/pages/profile/index.tsx +++ b/src/pages/profile/index.tsx @@ -1,48 +1,49 @@ +import { useEffect, useState } from 'react' +import { Link, useNavigate, useParams } from 'react-router-dom' +import { toast } from 'react-toastify' + import ContentCopyIcon from '@mui/icons-material/ContentCopy' import EditIcon from '@mui/icons-material/Edit' import { Box, IconButton, SxProps, Theme, Typography } from '@mui/material' -import { Event, VerifiedEvent, kinds, nip19 } from 'nostr-tools' -import { useEffect, useMemo, useState } from 'react' -import { useAppSelector } from '../../hooks/store' -import { Link, useNavigate, useParams } from 'react-router-dom' -import { toast } from 'react-toastify' + +import { nip19 } from 'nostr-tools' + +import { Container } from '../../components/Container' +import { Footer } from '../../components/Footer/Footer' import { LoadingSpinner } from '../../components/LoadingSpinner' -import { MetadataController } from '../../controllers' +import { useAppSelector } from '../../hooks/store' + import { getProfileSettingsRoute } from '../../routes' -import { NostrJoiningBlock, ProfileMetadata } from '../../types' + import { - getNostrJoiningBlockNumber, getProfileUsername, getRoboHashPicture, hexToNpub, shorten } from '../../utils' + +import { NDKEvent, NDKUserProfile, profileFromEvent } from '@nostr-dev-kit/ndk' +import { useNDKContext } from '../../hooks' import styles from './style.module.scss' -import { Container } from '../../components/Container' -import { Footer } from '../../components/Footer/Footer' export const ProfilePage = () => { const navigate = useNavigate() const { npub } = useParams() - - const metadataController = useMemo(() => MetadataController.getInstance(), []) + const { ndk, findMetadata } = useNDKContext() const [pubkey, setPubkey] = useState() - const [nostrJoiningBlock, setNostrJoiningBlock] = - useState(null) - const [profileMetadata, setProfileMetadata] = useState() + const [userProfile, setUserProfile] = useState(null) + + const userRobotImage = useAppSelector((state) => state.userRobotImage) const metadataState = useAppSelector((state) => state.metadata) const { usersPubkey } = useAppSelector((state) => state.auth) - const userRobotImage = useAppSelector((state) => state.userRobotImage) const [isUsersOwnProfile, setIsUsersOwnProfile] = useState(false) const [isLoading, setIsLoading] = useState(true) const [loadingSpinnerDesc] = useState('Fetching metadata') - const profileName = pubkey && getProfileUsername(pubkey, profileMetadata) - useEffect(() => { if (npub) { try { @@ -57,60 +58,30 @@ export const ProfilePage = () => { }, [npub, usersPubkey]) useEffect(() => { - if (pubkey) { - getNostrJoiningBlockNumber(pubkey) - .then((res) => { - setNostrJoiningBlock(res) - }) - .catch((err) => { - // todo: handle error - console.log('err :>> ', err) - }) - } - if (isUsersOwnProfile && metadataState) { - const metadataContent = metadataController.extractProfileMetadataContent( - metadataState as VerifiedEvent - ) - if (metadataContent) { - setProfileMetadata(metadataContent) - setIsLoading(false) - } + const ndkEvent = new NDKEvent(ndk, metadataState) + const profile = profileFromEvent(ndkEvent) + + setUserProfile(profile) + + setIsLoading(false) return } if (pubkey) { - const getMetadata = async (pubkey: string) => { - const handleMetadataEvent = (event: Event) => { - const metadataContent = - metadataController.extractProfileMetadataContent(event) - if (metadataContent) { - setProfileMetadata(metadataContent) - } - } - - metadataController.on(pubkey, (kind: number, event: Event) => { - if (kind === kinds.Metadata) { - handleMetadataEvent(event) - } + findMetadata(pubkey) + .then((profile) => { + setUserProfile(profile) + }) + .catch((err) => { + toast.error(err) + }) + .finally(() => { + setIsLoading(false) }) - - const metadataEvent = await metadataController - .findMetadata(pubkey) - .catch((err) => { - toast.error(err) - return null - }) - - if (metadataEvent) handleMetadataEvent(metadataEvent) - - setIsLoading(false) - } - - getMetadata(pubkey) } - }, [isUsersOwnProfile, metadataState, pubkey, metadataController]) + }, [ndk, isUsersOwnProfile, metadataState, pubkey, findMetadata]) /** * Rendering text with button which copies the provided text @@ -146,29 +117,32 @@ export const ProfilePage = () => { * * @returns robohash image url */ - const getProfileImage = (metadata: ProfileMetadata) => { - if (!metadata) return '' + const getProfileImage = (profile: NDKUserProfile | null) => { + if (!profile) return getRoboHashPicture(npub) if (!isUsersOwnProfile) { - return metadata.picture || getRoboHashPicture(npub!) + return profile.image || getRoboHashPicture(npub!) } // userRobotImage is used only when visiting own profile // while kind 0 picture is not set - return metadata.picture || userRobotImage || getRoboHashPicture(npub!) + return profile.image || userRobotImage || getRoboHashPicture(npub!) } + const profileName = + pubkey && getProfileUsername(pubkey, userProfile || undefined) + return ( <> {isLoading && } {pubkey && ( - {profileMetadata && profileMetadata.banner ? ( + {userProfile && userProfile.banner ? ( {`banner ) : ( @@ -189,24 +163,12 @@ export const ProfilePage = () => { > {profileName} - - - {nostrJoiningBlock - ? `On nostr since ${nostrJoiningBlock.block.toLocaleString()}` - : 'On nostr since: unknown'} - - + {isUsersOwnProfile && ( { display: 'flex' }} > - {profileMetadata && ( - - {profileName} - - )} + + {profileName} + {textElementWithCopyIcon( @@ -242,42 +202,34 @@ export const ProfilePage = () => { )} - {profileMetadata?.nip05 && - textElementWithCopyIcon( - profileMetadata.nip05, - undefined, - 15 - )} + {userProfile?.nip05 && + textElementWithCopyIcon(userProfile.nip05, undefined, 15)} - {profileMetadata?.lud16 && - textElementWithCopyIcon( - profileMetadata.lud16, - undefined, - 15 - )} + {userProfile?.lud16 && + textElementWithCopyIcon(userProfile.lud16, undefined, 15)} - {profileMetadata?.website && ( + {userProfile?.website && ( - {profileMetadata.website} + {userProfile.website} )} - {profileMetadata?.about && ( + {userProfile?.about && ( - {profileMetadata.about} + {userProfile.about} )} diff --git a/src/pages/settings/profile/index.tsx b/src/pages/settings/profile/index.tsx index 1a3e34f..8432406 100644 --- a/src/pages/settings/profile/index.tsx +++ b/src/pages/settings/profile/index.tsx @@ -1,4 +1,11 @@ +import React, { useEffect, useRef, useState } from 'react' +import { useParams } from 'react-router-dom' +import { toast } from 'react-toastify' + +import { SmartToy } from '@mui/icons-material' import ContentCopyIcon from '@mui/icons-material/ContentCopy' +import LaunchIcon from '@mui/icons-material/Launch' +import { LoadingButton } from '@mui/lab' import { Box, IconButton, @@ -7,59 +14,53 @@ import { ListItem, ListSubheader, TextField, - Tooltip, - Typography, - useTheme + Tooltip } from '@mui/material' -import { UnsignedEvent, nip19, kinds, VerifiedEvent, Event } from 'nostr-tools' -import React, { useEffect, useRef, useState } from 'react' -import { Link, useParams } from 'react-router-dom' -import { toast } from 'react-toastify' -import { MetadataController, NostrController } from '../../../controllers' -import { NostrJoiningBlock, ProfileMetadata } from '../../../types' -import styles from './style.module.scss' + +import { + NDKEvent, + NDKUserProfile, + profileFromEvent, + serializeProfile +} from '@nostr-dev-kit/ndk' +import { launch as launchNostrLoginDialog } from 'nostr-login' +import { kinds, nip19, UnsignedEvent } from 'nostr-tools' + +import { NostrController } from '../../../controllers' + +import { useNDKContext } from '../../../hooks' import { useAppDispatch, useAppSelector } from '../../../hooks/store' -import { LoadingButton } from '@mui/lab' -import { Dispatch } from '../../../store/store' -import { setMetadataEvent } from '../../../store/actions' -import { LoadingSpinner } from '../../../components/LoadingSpinner' -import { LoginMethod, NostrLoginAuthMethod } from '../../../store/auth/types' -import { SmartToy } from '@mui/icons-material' -import { - getNostrJoiningBlockNumber, - getRoboHashPicture, - unixNow -} from '../../../utils' +import { getRoboHashPicture, unixNow } from '../../../utils' + import { Container } from '../../../components/Container' import { Footer } from '../../../components/Footer/Footer' -import LaunchIcon from '@mui/icons-material/Launch' -import { launch as launchNostrLoginDialog } from 'nostr-login' +import { LoadingSpinner } from '../../../components/LoadingSpinner' + +import { setMetadataEvent } from '../../../store/actions' +import { LoginMethod, NostrLoginAuthMethod } from '../../../store/auth/types' +import { Dispatch } from '../../../store/store' + +import styles from './style.module.scss' export const ProfileSettingsPage = () => { - const theme = useTheme() - - const { npub } = useParams() - const dispatch: Dispatch = useAppDispatch() - const metadataController = MetadataController.getInstance() - const nostrController = NostrController.getInstance() + const { npub } = useParams() + const { ndk, findMetadata, publish } = useNDKContext() const [pubkey, setPubkey] = useState() - const [nostrJoiningBlock, setNostrJoiningBlock] = - useState(null) - const [profileMetadata, setProfileMetadata] = useState() - const [savingProfileMetadata, setSavingProfileMetadata] = useState(false) + const [userProfile, setUserProfile] = useState(null) + + const userRobotImage = useAppSelector((state) => state.userRobotImage) const metadataState = useAppSelector((state) => state.metadata) const keys = useAppSelector((state) => state.auth?.keyPair) const { usersPubkey, loginMethod, nostrLoginAuthMethod } = useAppSelector( (state) => state.auth ) - const userRobotImage = useAppSelector((state) => state.userRobotImage) + const [savingProfileMetadata, setSavingProfileMetadata] = useState(false) const [isUsersOwnProfile, setIsUsersOwnProfile] = useState(false) - const [isLoading, setIsLoading] = useState(true) const [loadingSpinnerDesc] = useState('Fetching metadata') @@ -79,63 +80,33 @@ export const ProfileSettingsPage = () => { }, [npub, usersPubkey]) useEffect(() => { - if (pubkey) { - getNostrJoiningBlockNumber(pubkey) - .then((res) => { - setNostrJoiningBlock(res) - }) - .catch((err) => { - // todo: handle error - console.log('err :>> ', err) - }) - } - if (isUsersOwnProfile && metadataState) { - const metadataContent = metadataController.extractProfileMetadataContent( - metadataState as VerifiedEvent - ) - if (metadataContent) { - setProfileMetadata(metadataContent) - setIsLoading(false) - } + const ndkEvent = new NDKEvent(ndk, metadataState) + const profile = profileFromEvent(ndkEvent) + + setUserProfile(profile) + + setIsLoading(false) return } if (pubkey) { - const getMetadata = async (pubkey: string) => { - const handleMetadataEvent = (event: Event) => { - const metadataContent = - metadataController.extractProfileMetadataContent(event) - if (metadataContent) { - setProfileMetadata(metadataContent) - } - } - - metadataController.on(pubkey, (kind: number, event: Event) => { - if (kind === kinds.Metadata) { - handleMetadataEvent(event) - } + findMetadata(pubkey) + .then((profile) => { + setUserProfile(profile) + }) + .catch((err) => { + toast.error(err) + }) + .finally(() => { + setIsLoading(false) }) - - const metadataEvent = await metadataController - .findMetadata(pubkey) - .catch((err) => { - toast.error(err) - return null - }) - - if (metadataEvent) handleMetadataEvent(metadataEvent) - - setIsLoading(false) - } - - getMetadata(pubkey) } - }, [isUsersOwnProfile, metadataState, pubkey, metadataController]) + }, [ndk, isUsersOwnProfile, metadataState, pubkey, findMetadata]) const editItem = ( - key: keyof ProfileMetadata, + key: keyof NDKUserProfile, label: string, multiline = false, rows = 1, @@ -145,7 +116,7 @@ export const ProfileSettingsPage = () => { { onChange={(event: React.ChangeEvent) => { const { value } = event.target - setProfileMetadata((prev) => ({ + setUserProfile((prev) => ({ ...prev, [key]: value })) @@ -197,32 +168,45 @@ export const ProfileSettingsPage = () => { ) const handleSaveMetadata = async () => { + if (!userProfile) return + setSavingProfileMetadata(true) - const content = JSON.stringify(profileMetadata) + const serializedProfile = serializeProfile(userProfile) - // We need to omit cachedAt and create new event - // Relay will reject if created_at is too late - const updatedMetadataState: UnsignedEvent = { - content: content, + const unsignedEvent: UnsignedEvent = { + content: serializedProfile, created_at: unixNow(), kind: kinds.Metadata, pubkey: pubkey!, - tags: metadataState?.tags || [] + tags: [] } + const nostrController = NostrController.getInstance() const signedEvent = await nostrController - .signEvent(updatedMetadataState) + .signEvent(unsignedEvent) .catch((error) => { toast.error(`Error saving profile metadata. ${error}`) + return null }) - if (signedEvent) { - if (!metadataController.validate(signedEvent)) { - toast.error(`Metadata is not valid.`) - } + if (!signedEvent) { + setSavingProfileMetadata(false) + return + } - await metadataController.publishMetadataEvent(signedEvent) + const ndkEvent = new NDKEvent(ndk, signedEvent) + const publishedOnRelays = await publish(ndkEvent) + + // Handle cases where publishing failed or succeeded + if (publishedOnRelays.length === 0) { + toast.error('Failed to publish event on any relay') + } else { + toast.success( + `Event published successfully to the following relays\n\n${publishedOnRelays.join( + '\n' + )}` + ) dispatch(setMetadataEvent(signedEvent)) } @@ -241,7 +225,7 @@ export const ProfileSettingsPage = () => { const robotAvatarLink = getRoboHashPicture(npub!, robotSet.current) - setProfileMetadata((prev) => ({ + setUserProfile((prev) => ({ ...prev, picture: robotAvatarLink })) @@ -267,14 +251,14 @@ export const ProfileSettingsPage = () => { * * @returns robohash image url */ - const getProfileImage = (metadata: ProfileMetadata) => { + const getProfileImage = (profile: NDKUserProfile) => { if (!isUsersOwnProfile) { - return metadata.picture || getRoboHashPicture(npub!) + return profile.image || getRoboHashPicture(npub!) } // userRobotImage is used only when visiting own profile // while kind 0 picture is not set - return metadata.picture || userRobotImage || getRoboHashPicture(npub!) + return profile.image || userRobotImage || getRoboHashPicture(npub!) } return ( @@ -300,7 +284,7 @@ export const ProfileSettingsPage = () => { } > - {profileMetadata && ( + {userProfile && (
{ flexDirection: 'column' }} > - {profileMetadata.banner ? ( + {userProfile.banner ? ( Banner Image ) : ( @@ -334,24 +318,9 @@ export const ProfileSettingsPage = () => { event.currentTarget.src = getRoboHashPicture(npub!) }} className={styles.img} - src={getProfileImage(profileMetadata)} + src={getProfileImage(userProfile)} alt="Profile Image" /> - - {nostrJoiningBlock && ( - - On nostr since {nostrJoiningBlock.block.toLocaleString()} - - )} {editItem('picture', 'Picture URL', undefined, undefined, { @@ -368,6 +337,7 @@ export const ProfileSettingsPage = () => { <> {usersPubkey && copyItem(nip19.npubEncode(usersPubkey), 'Public Key')} + {loginMethod === LoginMethod.privateKey && keys && keys.private && -- 2.34.1 From 0cc1a320599b0153b20ea6fe23ba0e9ea8ea12a6 Mon Sep 17 00:00:00 2001 From: daniyal Date: Fri, 20 Dec 2024 15:25:02 +0500 Subject: [PATCH 03/16] chore(refactor): replace authContoller with useAuth hook --- src/App.tsx | 15 +-- src/controllers/AuthController.ts | 153 ------------------------------ src/hooks/index.ts | 2 + src/hooks/useAuth.ts | 127 +++++++++++++++++++++++++ src/layouts/Main.tsx | 42 ++++---- src/pages/nostr/index.tsx | 32 +++---- src/utils/auth.ts | 44 +++++++++ src/utils/dvm.ts | 140 +-------------------------- src/utils/index.ts | 3 +- src/utils/nostr.ts | 23 +---- 10 files changed, 227 insertions(+), 354 deletions(-) delete mode 100644 src/controllers/AuthController.ts create mode 100644 src/hooks/useAuth.ts create mode 100644 src/utils/auth.ts diff --git a/src/App.tsx b/src/App.tsx index 9f58f21..d1f9cc0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,17 +1,21 @@ import { useEffect } from 'react' -import { useAppSelector } from './hooks' import { Navigate, Route, Routes } from 'react-router-dom' -import { AuthController } from './controllers' + +import { useAppSelector, useAuth } from './hooks' + import { MainLayout } from './layouts/Main' + import { appPrivateRoutes, appPublicRoutes } from './routes' -import './App.scss' import { privateRoutes, publicRoutes, recursiveRouteRenderer } from './routes/util' +import './App.scss' + const App = () => { + const { checkSession } = useAuth() const authState = useAppSelector((state) => state.auth) useEffect(() => { @@ -22,9 +26,8 @@ const App = () => { window.location.hostname = 'localhost' } - const authController = new AuthController() - authController.checkSession() - }, []) + checkSession() + }, [checkSession]) const handleRootRedirect = () => { if (authState.loggedIn) return appPrivateRoutes.homePage diff --git a/src/controllers/AuthController.ts b/src/controllers/AuthController.ts deleted file mode 100644 index 9cdf85a..0000000 --- a/src/controllers/AuthController.ts +++ /dev/null @@ -1,153 +0,0 @@ -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, - saveAuthToken, - unixNow -} from '../utils' - -export class AuthController { - private nostrController: NostrController - private metadataController: MetadataController - - constructor() { - this.nostrController = NostrController.getInstance() - this.metadataController = MetadataController.getInstance() - } - - /** - * Function will authenticate user by signing an auth event - * which is done by calling the sign() function, where appropriate - * method will be chosen (extension or keys) - * - * @param pubkey of the user trying to login - * @returns url to redirect if authentication successfull - * or error if otherwise - */ - async authAndGetMetadataAndRelaysMap(pubkey: string) { - const emptyMetadata = this.metadataController.getEmptyMetadataEvent() - - this.metadataController - .findMetadata(pubkey) - .then((event) => { - if (event) { - store.dispatch(setMetadataEvent(event)) - } else { - store.dispatch(setMetadataEvent(emptyMetadata)) - } - }) - .catch((err) => { - console.warn('Error occurred while finding metadata', err) - - store.dispatch(setMetadataEvent(emptyMetadata)) - }) - - // Nostr uses unix timestamps - const timestamp = unixNow() - const { href } = window.location - - const authEvent: EventTemplate = { - kind: 27235, - tags: [ - ['u', href], - ['method', 'GET'] - ], - content: '', - created_at: timestamp - } - - const signedAuthEvent = await this.nostrController.signEvent(authEvent) - this.createAndSaveAuthToken(signedAuthEvent) - - store.dispatch( - setAuthState({ - loggedIn: true, - usersPubkey: pubkey - }) - ) - - const relayMap = await 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)) - } - - /** - * This block was added before we started using the `nostr-login` package - * At this point it seems it's not needed anymore and it's even blocking the flow (reloading on /verify) - * TODO to remove this if app works fine - */ - // const currentLocation = window.location.hash.replace('#', '') - - // if (!Object.values(appPrivateRoutes).includes(currentLocation)) { - // // Since verify is both public and private route, we don't use the `visitedLink` - // // value for it. Otherwise, when linking to /verify/:id we get redirected - // // to the root `/` - // if (currentLocation.includes(appPublicRoutes.verify)) { - // return Promise.resolve(currentLocation) - // } - // - // // User did change the location to one of the private routes - // const visitedLink = getVisitedLink() - // - // if (visitedLink) { - // const { pathname, search } = visitedLink - // - // return Promise.resolve(`${pathname}${search}`) - // } else { - // // Navigate user in - // return Promise.resolve(appPrivateRoutes.homePage) - // } - // } - } - - checkSession() { - const savedAuthToken = getAuthToken() - - if (savedAuthToken) { - const signedEvent = base64DecodeAuthToken(savedAuthToken) - - store.dispatch( - setAuthState({ - loggedIn: true, - usersPubkey: signedEvent.pubkey - }) - ) - - return - } - - store.dispatch( - setAuthState({ - loggedIn: false, - usersPubkey: undefined - }) - ) - } - - private createAndSaveAuthToken(signedAuthEvent: SignedEvent) { - const base64Encoded = base64EncodeSignedEvent(signedAuthEvent) - - // save newly created auth token (base64 nostr singed event) in local storage along with expiry time - saveAuthToken(base64Encoded) - return base64Encoded - } -} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 91dc278..e0c77b0 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,4 +1,6 @@ export * from './store' +export * from './useAuth' export * from './useDidMount' export * from './useDvm' +export * from './useLogout' export * from './useNDKContext' diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts new file mode 100644 index 0000000..4ad0cd0 --- /dev/null +++ b/src/hooks/useAuth.ts @@ -0,0 +1,127 @@ +import { Event, EventTemplate } from 'nostr-tools' +import { useCallback } from 'react' +import { NostrController } from '../controllers' +import { appPrivateRoutes } from '../routes' +import { + setAuthState, + setMetadataEvent, + setRelayMapAction +} from '../store/actions' +import { + base64DecodeAuthToken, + compareObjects, + createAndSaveAuthToken, + getAuthToken, + getEmptyMetadataEvent, + getRelayMap, + unixNow +} from '../utils' +import { useAppDispatch, useAppSelector } from './store' +import { useNDKContext } from './useNDKContext' + +export const useAuth = () => { + const dispatch = useAppDispatch() + const { findMetadata } = useNDKContext() + + const { auth: authState, relays: relaysState } = useAppSelector( + (state) => state + ) + + const checkSession = useCallback(() => { + const savedAuthToken = getAuthToken() + + if (savedAuthToken) { + const signedEvent = base64DecodeAuthToken(savedAuthToken) + + dispatch( + setAuthState({ + loggedIn: true, + usersPubkey: signedEvent.pubkey + }) + ) + return + } + + dispatch( + setAuthState({ + loggedIn: false, + usersPubkey: undefined + }) + ) + }, [dispatch]) + + /** + * Function will authenticate user by signing an auth event + * which is done by calling the sign() function, where appropriate + * method will be chosen (extension or keys) + * + * @param pubkey of the user trying to login + * @returns url to redirect if authentication successfull + * or error if otherwise + */ + const authAndGetMetadataAndRelaysMap = useCallback( + async (pubkey: string) => { + const emptyMetadata = getEmptyMetadataEvent() + + try { + const profile = await findMetadata(pubkey, {}, true) + + if (profile && profile.profileEvent) { + const event: Event = JSON.parse(profile.profileEvent) + dispatch(setMetadataEvent(event)) + } else { + dispatch(setMetadataEvent(emptyMetadata)) + } + } catch (err) { + console.warn('Error occurred while finding metadata', err) + dispatch(setMetadataEvent(emptyMetadata)) + } + + const timestamp = unixNow() + const { href } = window.location + + const authEvent: EventTemplate = { + kind: 27235, + tags: [ + ['u', href], + ['method', 'GET'] + ], + content: '', + created_at: timestamp + } + + const nostrController = NostrController.getInstance() + const signedAuthEvent = await nostrController.signEvent(authEvent) + createAndSaveAuthToken(signedAuthEvent) + + dispatch( + setAuthState({ + loggedIn: true, + usersPubkey: pubkey + }) + ) + + const relayMap = await getRelayMap(pubkey) + + if (Object.keys(relayMap).length < 1) { + // Navigate user to relays page if relay map is empty + return appPrivateRoutes.relays + } + + if ( + authState.loggedIn && + !compareObjects(relaysState?.map, relayMap.map) + ) { + dispatch(setRelayMapAction(relayMap.map)) + } + + return appPrivateRoutes.homePage + }, + [dispatch, findMetadata, authState, relaysState] + ) + + return { + authAndGetMetadataAndRelaysMap, + checkSession + } +} diff --git a/src/layouts/Main.tsx b/src/layouts/Main.tsx index 19ac4d9..2738bcb 100644 --- a/src/layouts/Main.tsx +++ b/src/layouts/Main.tsx @@ -1,13 +1,18 @@ -import { Event, getPublicKey, kinds, nip19 } from 'nostr-tools' import { useCallback, useEffect, useRef, useState } from 'react' import { Outlet, useNavigate, useSearchParams } from 'react-router-dom' + +import { Event, getPublicKey, kinds, nip19 } from 'nostr-tools' + +import { init as initNostrLogin } from 'nostr-login' +import { NostrLoginAuthOptions } from 'nostr-login/dist/types' + import { AppBar } from '../components/AppBar/AppBar' import { LoadingSpinner } from '../components/LoadingSpinner' -import { - AuthController, - MetadataController, - NostrController -} from '../controllers' + +import { MetadataController, NostrController } from '../controllers' + +import { useAppDispatch, useAppSelector, useAuth, useLogout } from '../hooks' + import { restoreState, setMetadataEvent, @@ -16,25 +21,25 @@ import { updateNostrLoginAuthMethod, updateUserAppData } from '../store/actions' +import { LoginMethod } from '../store/auth/types' import { setUserRobotImage } from '../store/userRobotImage/action' + import { getRoboHashPicture, getUsersAppData, loadState, subscribeForSigits } from '../utils' -import { useAppDispatch, useAppSelector } from '../hooks' + import styles from './style.module.scss' -import { useLogout } from '../hooks/useLogout' -import { LoginMethod } from '../store/auth/types' -import { NostrLoginAuthOptions } from 'nostr-login/dist/types' -import { init as initNostrLogin } from 'nostr-login' export const MainLayout = () => { const [searchParams, setSearchParams] = useSearchParams() const navigate = useNavigate() const dispatch = useAppDispatch() const logout = useLogout() + const { authAndGetMetadataAndRelaysMap } = useAuth() + const [isLoading, setIsLoading] = useState(true) const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState(`Loading App`) const isLoggedIn = useAppSelector((state) => state.auth?.loggedIn) @@ -59,13 +64,11 @@ export const MainLayout = () => { const login = useCallback(async () => { const nostrController = NostrController.getInstance() - const authController = new AuthController() const pubkey = await nostrController.capturePublicKey() dispatch(updateLoginMethod(LoginMethod.nostrLogin)) - const redirectPath = - await authController.authAndGetMetadataAndRelaysMap(pubkey) + const redirectPath = await authAndGetMetadataAndRelaysMap(pubkey) if (redirectPath) { navigateAfterLogin(redirectPath) @@ -105,13 +108,10 @@ export const MainLayout = () => { ) dispatch(updateLoginMethod(LoginMethod.privateKey)) - const authController = new AuthController() - authController - .authAndGetMetadataAndRelaysMap(publickey) - .catch((err) => { - console.error('Error occurred in authentication: ' + err) - return null - }) + authAndGetMetadataAndRelaysMap(publickey).catch((err) => { + console.error('Error occurred in authentication: ' + err) + return null + }) } catch (err) { console.error(`Error decoding the nsec. ${err}`) } diff --git a/src/pages/nostr/index.tsx b/src/pages/nostr/index.tsx index 5f2dc2f..738223a 100644 --- a/src/pages/nostr/index.tsx +++ b/src/pages/nostr/index.tsx @@ -1,28 +1,28 @@ -import { launch as launchNostrLoginDialog } from 'nostr-login' +import { useEffect, useState } from 'react' +import { useNavigate, useSearchParams } from 'react-router-dom' import { Button, Divider, TextField } from '@mui/material' -import { getPublicKey, nip19 } from 'nostr-tools' -import { useEffect, useState } from 'react' -import { useAppDispatch } from '../../hooks/store' -import { useNavigate, useSearchParams } from 'react-router-dom' import { toast } from 'react-toastify' -import { LoadingSpinner } from '../../components/LoadingSpinner' -import { AuthController } from '../../controllers' -import { updateKeyPair, updateLoginMethod } from '../../store/actions' -import { KeyboardCode } from '../../types' -import { LoginMethod } from '../../store/auth/types' + import { hexToBytes } from '@noble/hashes/utils' +import { launch as launchNostrLoginDialog } from 'nostr-login' +import { getPublicKey, nip19 } from 'nostr-tools' + +import { LoadingSpinner } from '../../components/LoadingSpinner' +import { useAppDispatch, useAuth } from '../../hooks' +import { updateKeyPair, updateLoginMethod } from '../../store/actions' +import { LoginMethod } from '../../store/auth/types' +import { KeyboardCode } from '../../types' import styles from './styles.module.scss' export const Nostr = () => { const [searchParams] = useSearchParams() + const { authAndGetMetadataAndRelaysMap } = useAuth() const dispatch = useAppDispatch() const navigate = useNavigate() - const authController = new AuthController() - const [isLoading, setIsLoading] = useState(false) const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') const [inputValue, setInputValue] = useState('') @@ -102,12 +102,12 @@ export const Nostr = () => { setIsLoading(true) setLoadingSpinnerDesc('Authenticating and finding metadata') - const redirectPath = await authController - .authAndGetMetadataAndRelaysMap(publickey) - .catch((err) => { + const redirectPath = await authAndGetMetadataAndRelaysMap(publickey).catch( + (err) => { toast.error('Error occurred in authentication: ' + err) return null - }) + } + ) if (redirectPath) navigateAfterLogin(redirectPath) diff --git a/src/utils/auth.ts b/src/utils/auth.ts new file mode 100644 index 0000000..afd91f9 --- /dev/null +++ b/src/utils/auth.ts @@ -0,0 +1,44 @@ +import { Event } from 'nostr-tools' +import { SignedEvent } from '../types' +import { saveAuthToken } from './localStorage' + +export const base64EncodeSignedEvent = (event: SignedEvent) => { + try { + const authEventSerialized = JSON.stringify(event) + const token = btoa(authEventSerialized) + return token + } catch (error) { + throw new Error('An error occurred in JSON.stringify of signedAuthEvent') + } +} + +export const base64DecodeAuthToken = (authToken: string): SignedEvent => { + const decodedToken = atob(authToken) + + try { + const signedEvent = JSON.parse(decodedToken) + return signedEvent + } catch (error) { + throw new Error('An error occurred in JSON.parse of the auth token') + } +} + +export const createAndSaveAuthToken = (signedAuthEvent: SignedEvent) => { + const base64Encoded = base64EncodeSignedEvent(signedAuthEvent) + + // save newly created auth token (base64 nostr signed event) in local storage along with expiry time + saveAuthToken(base64Encoded) + return base64Encoded +} + +export const getEmptyMetadataEvent = (pubkey?: string): Event => { + return { + content: '', + created_at: new Date().valueOf(), + id: '', + kind: 0, + pubkey: pubkey || '', + sig: '', + tags: [] + } +} diff --git a/src/utils/dvm.ts b/src/utils/dvm.ts index c2f33e8..24d637e 100644 --- a/src/utils/dvm.ts +++ b/src/utils/dvm.ts @@ -1,140 +1,10 @@ -import { EventTemplate, Filter, kinds, nip19 } from 'nostr-tools' -import { compareObjects, queryNip05, unixNow } from '.' -import { - MetadataController, - NostrController, - relayController -} from '../controllers' -import { NostrJoiningBlock, RelayInfoObject } from '../types' import NDK, { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk' -import store from '../store/store' +import { EventTemplate } from 'nostr-tools' +import { compareObjects, unixNow } from '.' +import { NostrController, relayController } from '../controllers' import { setRelayInfoAction } from '../store/actions' - -export const getNostrJoiningBlockNumber = async ( - hexKey: string -): Promise => { - const metadataController = MetadataController.getInstance() - - const relaySet = await metadataController.findRelayListMetadata(hexKey) - - const userRelays: string[] = [] - - // find user's relays - if (relaySet.write.length > 0) { - userRelays.push(...relaySet.write) - } else { - const metadata = await metadataController.findMetadata(hexKey) - if (!metadata) return null - - const metadataContent = - metadataController.extractProfileMetadataContent(metadata) - - if (metadataContent?.nip05) { - const nip05Profile = await queryNip05(metadataContent.nip05) - - if (nip05Profile && nip05Profile.pubkey === hexKey) { - userRelays.push(...nip05Profile.relays) - } - } - } - - if (userRelays.length === 0) return null - - // filter for finding user's first kind 0 event - const eventFilter: Filter = { - kinds: [kinds.Metadata], - authors: [hexKey] - } - - // find user's kind 0 event published on user's relays - const event = await relayController.fetchEvent(eventFilter, userRelays) - - if (event) { - const { created_at } = event - - // initialize job request - const jobEventTemplate: EventTemplate = { - content: '', - created_at: unixNow(), - kind: 68001, - tags: [ - ['i', `${created_at * 1000}`], - ['j', 'blockChain-block-number'] - ] - } - - 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' - ] - - await relayController.publish(jobSignedEvent, relays).catch((err) => { - console.error( - 'Error occurred in publish blockChain-block-number DVM job', - err - ) - }) - - 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) - - const encodedEventPointer = nip19.neventEncode({ - id: event.id, - relays: userRelays, - author: event.pubkey, - kind: event.kind - }) - - return { - block: parseInt(dvmJobResult), - encodedEventPointer - } - } - - return null -} +import store from '../store/store' +import { RelayInfoObject } from '../types' /** * Sets information about relays into relays.info app state. diff --git a/src/utils/index.ts b/src/utils/index.ts index 791c39b..1b00e7f 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,5 @@ +export * from './auth' +export * from './const' export * from './crypto' export * from './dvm' export * from './hash' @@ -11,4 +13,3 @@ export * from './string' export * from './url' export * from './utils' export * from './zip' -export * from './const' diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index e7dbcbe..3775837 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -199,27 +199,6 @@ export const queryNip05 = async ( } } -export const base64EncodeSignedEvent = (event: SignedEvent) => { - try { - const authEventSerialized = JSON.stringify(event) - const token = btoa(authEventSerialized) - return token - } catch (error) { - throw new Error('An error occurred in JSON.stringify of signedAuthEvent') - } -} - -export const base64DecodeAuthToken = (authToken: string): SignedEvent => { - const decodedToken = atob(authToken) - - try { - const signedEvent = JSON.parse(decodedToken) - return signedEvent - } catch (error) { - throw new Error('An error occurred in JSON.parse of the auth token') - } -} - /** * @param pubkey in hex or npub format * @returns robohash.org url for the avatar @@ -985,7 +964,7 @@ export const sendNotification = async (receiver: string, meta: Meta) => { */ export const getProfileUsername = ( npub: `npub1${string}` | string, - profile?: ProfileMetadata + profile?: ProfileMetadata // todo: use NDKUserProfile ) => truncate(profile?.display_name || profile?.name || hexToNpub(npub), { length: 16 -- 2.34.1 From 458de18f12f35b4d5a7f5172135c27764c589f7a Mon Sep 17 00:00:00 2001 From: daniyal Date: Fri, 20 Dec 2024 15:52:45 +0500 Subject: [PATCH 04/16] chore(refactore): use NDKContext for findMetadata instead of metadata controller --- src/components/DrawPDFFields/index.tsx | 18 +-- src/components/UserAvatar/index.tsx | 2 +- src/contexts/NDKContext.tsx | 21 ++- src/controllers/MetadataController.ts | 167 +----------------------- src/hooks/useProfileMetadata.tsx | 41 ++---- src/layouts/Main.tsx | 38 +++--- src/pages/create/index.tsx | 50 +++---- src/pages/sign/internal/displayMeta.tsx | 79 +++-------- 8 files changed, 98 insertions(+), 318 deletions(-) diff --git a/src/components/DrawPDFFields/index.tsx b/src/components/DrawPDFFields/index.tsx index 09c7d8f..73384c5 100644 --- a/src/components/DrawPDFFields/index.tsx +++ b/src/components/DrawPDFFields/index.tsx @@ -9,7 +9,7 @@ import { } from '@mui/material' import styles from './style.module.scss' import React, { useEffect, useState } from 'react' -import { ProfileMetadata, User, UserRole, KeyboardCode } from '../../types' +import { User, UserRole, KeyboardCode } from '../../types' import { MouseState, PdfPage, DrawnField, DrawTool } from '../../types/drawing' import { hexToNpub, npubToHex, getProfileUsername } from '../../utils' import { SigitFile } from '../../utils/file' @@ -21,6 +21,7 @@ import { useScale } from '../../hooks/useScale' import { AvatarIconButton } from '../UserAvatarIconButton' import { UserAvatar } from '../UserAvatar' import _ from 'lodash' +import { NDKUserProfile } from '@nostr-dev-kit/ndk' const DEFAULT_START_SIZE = { width: 140, @@ -33,7 +34,7 @@ interface HideSignersForDrawnField { interface Props { users: User[] - metadata: { [key: string]: ProfileMetadata } + userProfiles: { [key: string]: NDKUserProfile } sigitFiles: SigitFile[] setSigitFiles: React.Dispatch> selectedTool?: DrawTool @@ -563,10 +564,11 @@ export const DrawPDFFields = (props: Props) => { > {signers.map((signer, index) => { const npub = hexToNpub(signer.pubkey) - const metadata = props.metadata[signer.pubkey] + const profile = + props.userProfiles[signer.pubkey] const displayValue = getProfileUsername( npub, - metadata + profile ) // make current signers dropdown visible if ( @@ -585,7 +587,7 @@ export const DrawPDFFields = (props: Props) => { { const signer = signers.find((u) => u.pubkey === npubToHex(npub)) if (signer) { - const metadata = props.metadata[signer.pubkey] - displayValue = getProfileUsername(npub, metadata) + const profile = props.userProfiles[signer.pubkey] + displayValue = getProfileUsername(npub, profile) return (
{ const profile = useProfileMetadata(pubkey) const name = getProfileUsername(pubkey, profile) - const image = profile?.picture + const image = profile?.image return ( Promise - findMetadata: (pubkey: string) => Promise + findMetadata: ( + pubkey: string, + opts?: NDKSubscriptionOptions, + storeProfileEvent?: boolean + ) => Promise publish: (event: NDKEvent, explicitRelayUrls?: string[]) => Promise } @@ -191,16 +196,22 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => { * @returns A promise that resolves to the metadata event. */ const findMetadata = async ( - pubkey: string + pubkey: string, + opts?: NDKSubscriptionOptions, + storeProfileEvent?: boolean ): Promise => { const npub = hexToNpub(pubkey) const user = new NDKUser({ npub }) user.ndk = ndk - return await user.fetchProfile({ - cacheUsage: NDKSubscriptionCacheUsage.PARALLEL - }) + return await user.fetchProfile( + { + cacheUsage: NDKSubscriptionCacheUsage.PARALLEL, + ...(opts || {}) + }, + storeProfileEvent + ) } const publish = async ( diff --git a/src/controllers/MetadataController.ts b/src/controllers/MetadataController.ts index 984afd3..1f7cf8e 100644 --- a/src/controllers/MetadataController.ts +++ b/src/controllers/MetadataController.ts @@ -1,35 +1,19 @@ -import { - Event, - Filter, - VerifiedEvent, - kinds, - validateEvent, - verifyEvent -} from 'nostr-tools' -import { toast } from 'react-toastify' +import { Event } from 'nostr-tools' import { EventEmitter } from 'tseep' -import { NostrController, relayController } from '.' -import { localCache } from '../services' import { ProfileMetadata, RelaySet } from '../types' import { findRelayListAndUpdateCache, findRelayListInCache, getDefaultRelaySet, - getUserRelaySet, - isOlderThanOneDay, - unixNow + getUserRelaySet } from '../utils' import { DEFAULT_LOOK_UP_RELAY_LIST } from '../utils/const' export class MetadataController extends EventEmitter { private static instance: MetadataController - private nostrController: NostrController - private specialMetadataRelay = 'wss://purplepag.es' - private pendingFetches = new Map>() // Track pending fetches constructor() { super() - this.nostrController = NostrController.getInstance() } public static getInstance(): MetadataController { @@ -39,105 +23,6 @@ export class MetadataController extends EventEmitter { return MetadataController.instance } - /** - * Asynchronously checks for more recent metadata events authored by a specific key. - * If a more recent metadata event is found, it is handled and returned. - * If no more recent event is found, the current event is returned. - * @param hexKey The hexadecimal key of the author to filter metadata events. - * @param currentEvent The current metadata event, if any, to compare with newer events. - * @returns A promise resolving to the most recent metadata event found, or null if none is found. - */ - private async checkForMoreRecentMetadata( - hexKey: string, - currentEvent: Event | null - ): Promise { - // Return the ongoing fetch promise if one exists for the same hexKey - if (this.pendingFetches.has(hexKey)) { - return this.pendingFetches.get(hexKey)! - } - - // Define the event filter to only include metadata events authored by the given key - const eventFilter: Filter = { - kinds: [kinds.Metadata], - authors: [hexKey] - } - - const fetchPromise = relayController - .fetchEvent(eventFilter, DEFAULT_LOOK_UP_RELAY_LIST) - .catch((err) => { - console.error(err) - return null - }) - .finally(() => { - this.pendingFetches.delete(hexKey) - }) - - this.pendingFetches.set(hexKey, fetchPromise) - - const metadataEvent = await fetchPromise - - if ( - metadataEvent && - validateEvent(metadataEvent) && - verifyEvent(metadataEvent) - ) { - if ( - !currentEvent || - metadataEvent.created_at >= currentEvent.created_at - ) { - this.handleNewMetadataEvent(metadataEvent) - } - return metadataEvent - } - - // todo/implement: if no valid metadata event is found in DEFAULT_LOOK_UP_RELAY_LIST - // try to query user relay list - - // if current event is null we should cache empty metadata event for provided hexKey - if (!currentEvent) { - const emptyMetadata = this.getEmptyMetadataEvent(hexKey) - this.handleNewMetadataEvent(emptyMetadata as VerifiedEvent) - } - - return currentEvent - } - - /** - * Handle new metadata events and emit them to subscribers - */ - private async handleNewMetadataEvent(event: VerifiedEvent) { - // update the event in local cache - localCache.addUserMetadata(event) - // Emit the event to subscribers. - this.emit(event.pubkey, event.kind, event) - } - - /** - * Finds metadata for a given hexadecimal key. - * - * @param hexKey - The hexadecimal key to search for metadata. - * @returns A promise that resolves to the metadata event. - */ - public findMetadata = async (hexKey: string): Promise => { - // Attempt to retrieve the metadata event from the local cache - const cachedMetadataEvent = await localCache.getUserMetadata(hexKey) - - // If cached metadata is found, check its validity - if (cachedMetadataEvent) { - // Check if the cached metadata is older than one day - if (isOlderThanOneDay(cachedMetadataEvent.cachedAt)) { - // If older than one week, find the metadata from relays in background - this.checkForMoreRecentMetadata(hexKey, cachedMetadataEvent.event) - } - - // Return the cached metadata event - return cachedMetadataEvent.event - } - - // If no cached metadata is found, retrieve it from relays - return this.checkForMoreRecentMetadata(hexKey, null) - } - /** * Based on the hexKey of the current user, this method attempts to retrieve a relay set. * @func findRelayListInCache first checks if there is already an up-to-date @@ -168,52 +53,4 @@ export class MetadataController extends EventEmitter { return null } } - - /** - * Function will not sign provided event if the SIG exists - */ - public publishMetadataEvent = async (event: Event) => { - let signedMetadataEvent = event - - if (event.sig.length < 1) { - const timestamp = unixNow() - - // Metadata event to publish to the wss://purplepag.es relay - const newMetadataEvent: Event = { - ...event, - created_at: timestamp - } - - signedMetadataEvent = - await this.nostrController.signEvent(newMetadataEvent) - } - - await relayController - .publish(signedMetadataEvent, [this.specialMetadataRelay]) - .then((relays) => { - if (relays.length) { - toast.success(`Metadata event published on: ${relays.join('\n')}`) - this.handleNewMetadataEvent(signedMetadataEvent as VerifiedEvent) - } else { - toast.error('Could not publish metadata event to any relay!') - } - }) - .catch((err) => { - toast.error(err.message) - }) - } - - public validate = (event: Event) => validateEvent(event) && verifyEvent(event) - - public getEmptyMetadataEvent = (pubkey?: string): Event => { - return { - content: '', - created_at: new Date().valueOf(), - id: '', - kind: 0, - pubkey: pubkey || '', - sig: '', - tags: [] - } - } } diff --git a/src/hooks/useProfileMetadata.tsx b/src/hooks/useProfileMetadata.tsx index f746f0d..9532bb3 100644 --- a/src/hooks/useProfileMetadata.tsx +++ b/src/hooks/useProfileMetadata.tsx @@ -1,33 +1,18 @@ import { useEffect, useState } from 'react' -import { ProfileMetadata } from '../types/profile' -import { MetadataController } from '../controllers/MetadataController' -import { Event, kinds } from 'nostr-tools' + +import { NDKUserProfile } from '@nostr-dev-kit/ndk' +import { useNDKContext } from './useNDKContext' export const useProfileMetadata = (pubkey: string) => { - const [profileMetadata, setProfileMetadata] = useState() + const { findMetadata } = useNDKContext() + + const [userProfile, setUserProfile] = useState() useEffect(() => { - const metadataController = MetadataController.getInstance() - const handleMetadataEvent = (event: Event) => { - const metadataContent = - metadataController.extractProfileMetadataContent(event) - - if (metadataContent) { - setProfileMetadata(metadataContent) - } - } - if (pubkey) { - metadataController.on(pubkey, (kind: number, event: Event) => { - if (kind === kinds.Metadata) { - handleMetadataEvent(event) - } - }) - - metadataController - .findMetadata(pubkey) - .then((metadataEvent) => { - if (metadataEvent) handleMetadataEvent(metadataEvent) + findMetadata(pubkey) + .then((profile) => { + if (profile) setUserProfile(profile) }) .catch((err) => { console.error( @@ -36,11 +21,7 @@ export const useProfileMetadata = (pubkey: string) => { ) }) } + }, [pubkey, findMetadata]) - return () => { - metadataController.off(pubkey, handleMetadataEvent) - } - }, [pubkey]) - - return profileMetadata + return userProfile } diff --git a/src/layouts/Main.tsx b/src/layouts/Main.tsx index 2738bcb..b373bf5 100644 --- a/src/layouts/Main.tsx +++ b/src/layouts/Main.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { Outlet, useNavigate, useSearchParams } from 'react-router-dom' -import { Event, getPublicKey, kinds, nip19 } from 'nostr-tools' +import { Event, getPublicKey, nip19 } from 'nostr-tools' import { init as initNostrLogin } from 'nostr-login' import { NostrLoginAuthOptions } from 'nostr-login/dist/types' @@ -9,9 +9,15 @@ import { NostrLoginAuthOptions } from 'nostr-login/dist/types' import { AppBar } from '../components/AppBar/AppBar' import { LoadingSpinner } from '../components/LoadingSpinner' -import { MetadataController, NostrController } from '../controllers' +import { NostrController } from '../controllers' -import { useAppDispatch, useAppSelector, useAuth, useLogout } from '../hooks' +import { + useAppDispatch, + useAppSelector, + useAuth, + useLogout, + useNDKContext +} from '../hooks' import { restoreState, @@ -38,6 +44,7 @@ export const MainLayout = () => { const navigate = useNavigate() const dispatch = useAppDispatch() const logout = useLogout() + const { findMetadata } = useNDKContext() const { authAndGetMetadataAndRelaysMap } = useAuth() const [isLoading, setIsLoading] = useState(true) @@ -143,8 +150,6 @@ export const MainLayout = () => { }, [dispatch]) useEffect(() => { - const metadataController = MetadataController.getInstance() - const restoredState = loadState() if (restoredState) { dispatch(restoreState(restoredState)) @@ -154,20 +159,19 @@ export const MainLayout = () => { if (loggedIn) { if (!loginMethod || !usersPubkey) return logout() - // Update user profile metadata, old state might be outdated - const handleMetadataEvent = (event: Event) => { - dispatch(setMetadataEvent(event)) - } - - metadataController.on(usersPubkey, (kind: number, event: Event) => { - if (kind === kinds.Metadata) { - handleMetadataEvent(event) + findMetadata(usersPubkey, {}, true).then((profile) => { + if (profile && profile.profileEvent) { + try { + const event: Event = JSON.parse(profile.profileEvent) + dispatch(setMetadataEvent(event)) + } catch (error) { + console.error( + 'An error occurred in parsing profile event from profile obj', + error + ) + } } }) - - metadataController.findMetadata(usersPubkey).then((metadataEvent) => { - if (metadataEvent) handleMetadataEvent(metadataEvent) - }) } else { setIsLoading(false) } diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index a65a559..e861545 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -10,7 +10,7 @@ import { import type { Identifier, XYCoord } from 'dnd-core' import saveAs from 'file-saver' import JSZip from 'jszip' -import { Event, kinds } from 'nostr-tools' +import { Event } from 'nostr-tools' import { useCallback, useEffect, useRef, useState } from 'react' import { DndProvider, useDrag, useDrop } from 'react-dnd' import { MultiBackend } from 'react-dnd-multi-backend' @@ -30,7 +30,6 @@ import { CreateSignatureEventContent, KeyboardCode, Meta, - ProfileMetadata, SignedEvent, User, UserRole @@ -80,12 +79,16 @@ import { Autocomplete } from '@mui/lab' import _, { truncate } from 'lodash' import * as React from 'react' import { AvatarIconButton } from '../../components/UserAvatarIconButton' +import { NDKUserProfile } from '@nostr-dev-kit/ndk' +import { useNDKContext } from '../../hooks/useNDKContext.ts' type FoundUser = Event & { npub: string } export const CreatePage = () => { const navigate = useNavigate() const location = useLocation() + const { findMetadata } = useNDKContext() + const { uploadedFiles } = location.state || {} const [currentFile, setCurrentFile] = useState() const isActive = (file: File) => file.name === currentFile?.name @@ -117,9 +120,10 @@ export const CreatePage = () => { const nostrController = NostrController.getInstance() - const [metadata, setMetadata] = useState<{ [key: string]: ProfileMetadata }>( - {} - ) + const [userProfiles, setUserProfiles] = useState<{ + [key: string]: NDKUserProfile + }>({}) + const [drawnFiles, setDrawnFiles] = useState([]) const [parsingPdf, setIsParsing] = useState(false) @@ -280,29 +284,15 @@ export const CreatePage = () => { useEffect(() => { users.forEach((user) => { - if (!(user.pubkey in metadata)) { - const metadataController = MetadataController.getInstance() - - const handleMetadataEvent = (event: Event) => { - const metadataContent = - metadataController.extractProfileMetadataContent(event) - if (metadataContent) - setMetadata((prev) => ({ - ...prev, - [user.pubkey]: metadataContent - })) - } - - metadataController.on(user.pubkey, (kind: number, event: Event) => { - if (kind === kinds.Metadata) { - handleMetadataEvent(event) - } - }) - - metadataController - .findMetadata(user.pubkey) - .then((metadataEvent) => { - if (metadataEvent) handleMetadataEvent(metadataEvent) + if (!(user.pubkey in userProfiles)) { + findMetadata(user.pubkey) + .then((profile) => { + if (profile) { + setUserProfiles((prev) => ({ + ...prev, + [user.pubkey]: profile + })) + } }) .catch((err) => { console.error( @@ -312,7 +302,7 @@ export const CreatePage = () => { }) } }) - }, [metadata, users]) + }, [userProfiles, users, findMetadata]) useEffect(() => { if (uploadedFiles) { @@ -1204,7 +1194,7 @@ export const CreatePage = () => { ) : ( ( - {} - ) const [users, setUsers] = useState([]) useEffect(() => { @@ -104,45 +100,6 @@ export const DisplayMeta = ({ }) }, [signers, viewers]) - useEffect(() => { - const metadataController = MetadataController.getInstance() - - const hexKeys: string[] = [ - npubToHex(submittedBy)!, - ...users.map((user) => user.pubkey) - ] - - hexKeys.forEach((key) => { - if (!(key in metadata)) { - const handleMetadataEvent = (event: Event) => { - const metadataContent = - metadataController.extractProfileMetadataContent(event) - - if (metadataContent) - setMetadata((prev) => ({ - ...prev, - [key]: metadataContent - })) - } - - metadataController.on(key, (kind: number, event: Event) => { - if (kind === kinds.Metadata) { - handleMetadataEvent(event) - } - }) - - metadataController - .findMetadata(key) - .then((metadataEvent) => { - if (metadataEvent) handleMetadataEvent(metadataEvent) - }) - .catch((err) => { - console.error(`error occurred in finding metadata for: ${key}`, err) - }) - } - }) - }, [users, submittedBy, metadata]) - const downloadFile = async (fileName: string) => { const file = files[fileName] saveAs(file) @@ -229,7 +186,6 @@ export const DisplayMeta = ({ key={user.pubkey} meta={meta} user={user} - metadata={metadata} signedBy={signedBy} nextSigner={nextSigner} getPrevSignersSig={getPrevSignersSig} @@ -258,7 +214,6 @@ enum UserStatus { type DisplayUserProps = { meta: Meta user: User - metadata: { [key: string]: ProfileMetadata } signedBy: `npub1${string}`[] nextSigner?: string getPrevSignersSig: (usersNpub: string) => string | null -- 2.34.1 From 5c24c5bde03a7dc8c69ddbf2a4dc0bb0f97ddff6 Mon Sep 17 00:00:00 2001 From: daniyal Date: Fri, 20 Dec 2024 21:58:45 +0500 Subject: [PATCH 05/16] chore(refactor): use getNDKRelayList function from NDKContext instead of findRelayListMetadata function of metadata controller --- src/contexts/NDKContext.tsx | 18 ++++++ src/controllers/MetadataController.ts | 56 ----------------- src/controllers/index.ts | 2 - src/layouts/Main.tsx | 8 +-- src/pages/create/index.tsx | 18 +++--- src/pages/sign/index.tsx | 4 +- src/pages/verify/index.tsx | 5 +- src/utils/nostr.ts | 87 +++++++++------------------ 8 files changed, 64 insertions(+), 134 deletions(-) delete mode 100644 src/controllers/MetadataController.ts diff --git a/src/contexts/NDKContext.tsx b/src/contexts/NDKContext.tsx index c44252b..efc47e9 100644 --- a/src/contexts/NDKContext.tsx +++ b/src/contexts/NDKContext.tsx @@ -1,7 +1,9 @@ import NDK, { getRelayListForUser, + Hexpubkey, NDKEvent, NDKFilter, + NDKRelayList, NDKRelaySet, NDKSubscriptionCacheUsage, NDKSubscriptionOptions, @@ -19,6 +21,7 @@ import { DEFAULT_LOOK_UP_RELAY_LIST, hexToNpub, orderEventsChronologically, + SIGIT_RELAY, timeout } from '../utils' @@ -41,6 +44,7 @@ export interface NDKContextType { opts?: NDKSubscriptionOptions, storeProfileEvent?: boolean ) => Promise + getNDKRelayList: (pubkey: Hexpubkey) => Promise publish: (event: NDKEvent, explicitRelayUrls?: string[]) => Promise } @@ -214,6 +218,19 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => { ) } + const getNDKRelayList = async (pubkey: Hexpubkey) => { + const ndkRelayList = await Promise.race([ + getRelayListForUser(pubkey, ndk), + timeout(10000) + ]).catch(() => { + const relayList = new NDKRelayList(ndk) + relayList.bothRelayUrls = [SIGIT_RELAY] + return relayList + }) + + return ndkRelayList + } + const publish = async ( event: NDKEvent, explicitRelayUrls?: string[] @@ -247,6 +264,7 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => { fetchEventsFromUserRelays, fetchEventFromUserRelays, findMetadata, + getNDKRelayList, publish }} > diff --git a/src/controllers/MetadataController.ts b/src/controllers/MetadataController.ts deleted file mode 100644 index 1f7cf8e..0000000 --- a/src/controllers/MetadataController.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Event } from 'nostr-tools' -import { EventEmitter } from 'tseep' -import { ProfileMetadata, RelaySet } from '../types' -import { - findRelayListAndUpdateCache, - findRelayListInCache, - getDefaultRelaySet, - getUserRelaySet -} from '../utils' -import { DEFAULT_LOOK_UP_RELAY_LIST } from '../utils/const' - -export class MetadataController extends EventEmitter { - private static instance: MetadataController - - constructor() { - super() - } - - public static getInstance(): MetadataController { - if (!MetadataController.instance) { - MetadataController.instance = new MetadataController() - } - return MetadataController.instance - } - - /** - * Based on the hexKey of the current user, this method attempts to retrieve a relay set. - * @func findRelayListInCache first checks if there is already an up-to-date - * relay list available in cache; if not - - * @func findRelayListAndUpdateCache checks if the relevant relay event is available from - * the purple pages relay; - * @func findRelayListAndUpdateCache will run again if the previous two calls return null and - * check if the relevant relay event can be obtained from 'most popular relays' - * If relay event is found, it will be saved in cache for future use - * @param hexKey of the current user - * @return RelaySet which will contain either relays extracted from the user Relay Event - * or a fallback RelaySet with Sigit's Relay - */ - public findRelayListMetadata = async (hexKey: string): Promise => { - const relayEvent = - (await findRelayListInCache(hexKey)) || - (await findRelayListAndUpdateCache(DEFAULT_LOOK_UP_RELAY_LIST, hexKey)) - - return relayEvent ? getUserRelaySet(relayEvent.tags) : getDefaultRelaySet() - } - - public extractProfileMetadataContent = (event: Event) => { - try { - if (!event.content) return {} - return JSON.parse(event.content) as ProfileMetadata - } catch (error) { - console.log('error in parsing metadata event content :>> ', error) - return null - } - } -} diff --git a/src/controllers/index.ts b/src/controllers/index.ts index dc1f76f..63c9671 100644 --- a/src/controllers/index.ts +++ b/src/controllers/index.ts @@ -1,4 +1,2 @@ -export * from './AuthController' -export * from './MetadataController' export * from './NostrController' export * from './RelayController' diff --git a/src/layouts/Main.tsx b/src/layouts/Main.tsx index b373bf5..4409224 100644 --- a/src/layouts/Main.tsx +++ b/src/layouts/Main.tsx @@ -44,7 +44,7 @@ export const MainLayout = () => { const navigate = useNavigate() const dispatch = useAppDispatch() const logout = useLogout() - const { findMetadata } = useNDKContext() + const { findMetadata, getNDKRelayList } = useNDKContext() const { authAndGetMetadataAndRelaysMap } = useAuth() const [isLoading, setIsLoading] = useState(true) @@ -191,13 +191,13 @@ export const MainLayout = () => { if (pubkey && !hasSubscribed.current) { // Call `subscribeForSigits` only if it hasn't been called before // #193 disabled websocket subscribtion, until #194 is done - subscribeForSigits(pubkey) + subscribeForSigits(pubkey, getNDKRelayList) // Mark `subscribeForSigits` as called hasSubscribed.current = true } } - }, [authState, isLoggedIn, usersAppData]) + }, [authState, isLoggedIn, usersAppData, getNDKRelayList]) /** * When authState change user logged in / or app reloaded @@ -214,7 +214,7 @@ export const MainLayout = () => { setIsLoading(true) setLoadingSpinnerDesc(`Loading SIGit history...`) - getUsersAppData() + getUsersAppData(getNDKRelayList) .then((appData) => { if (appData) { dispatch(updateUserAppData(appData)) diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index e861545..b2d6f5d 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -20,11 +20,7 @@ import { useLocation, useNavigate } from 'react-router-dom' import { toast } from 'react-toastify' import { LoadingSpinner } from '../../components/LoadingSpinner' import { UserAvatar } from '../../components/UserAvatar' -import { - MetadataController, - NostrController, - RelayController -} from '../../controllers' +import { NostrController, RelayController } from '../../controllers' import { appPrivateRoutes } from '../../routes' import { CreateSignatureEventContent, @@ -87,7 +83,7 @@ type FoundUser = Event & { npub: string } export const CreatePage = () => { const navigate = useNavigate() const location = useLocation() - const { findMetadata } = useNDKContext() + const { findMetadata, getNDKRelayList } = useNDKContext() const { uploadedFiles } = location.state || {} const [currentFile, setCurrentFile] = useState() @@ -160,18 +156,18 @@ export const CreatePage = () => { setSearchUsersLoading(true) const relayController = RelayController.getInstance() - const metadataController = MetadataController.getInstance() - const relaySet = await metadataController.findRelayListMetadata(usersPubkey) const searchTerm = searchString.trim() + const ndkRelayList = await getNDKRelayList(usersPubkey) + relayController .fetchEvents( { kinds: [0], search: searchTerm }, - [...relaySet.write] + [...ndkRelayList.writeRelayUrls] ) .then((events) => { console.log('events', events) @@ -777,7 +773,9 @@ export const CreatePage = () => { : viewers.map((viewer) => viewer.pubkey) ).filter((receiver) => receiver !== usersPubkey) - return receivers.map((receiver) => sendNotification(receiver, meta)) + return receivers.map((receiver) => + sendNotification(receiver, meta, getNDKRelayList) + ) } const extractNostrId = (stringifiedEvent: string): string => { diff --git a/src/pages/sign/index.tsx b/src/pages/sign/index.tsx index f30ecdd..f874122 100644 --- a/src/pages/sign/index.tsx +++ b/src/pages/sign/index.tsx @@ -56,6 +56,7 @@ import { import { ARRAY_BUFFER, DEFLATE } from '../../utils/const.ts' import { generateTimestamp } from '../../utils/opentimestamps.ts' import { MARK_TYPE_CONFIG } from '../../components/MarkTypeStrategy/MarkStrategy.tsx' +import { useNDKContext } from '../../hooks/useNDKContext.ts' enum SignedStatus { Fully_Signed, @@ -67,6 +68,7 @@ export const SignPage = () => { const navigate = useNavigate() const location = useLocation() const params = useParams() + const { getNDKRelayList } = useNDKContext() const usersAppData = useAppSelector((state) => state.userAppData) @@ -781,7 +783,7 @@ export const SignPage = () => { setLoadingSpinnerDesc('Sending notifications') const users = Array.from(userSet) const promises = users.map((user) => - sendNotification(npubToHex(user)!, meta) + sendNotification(npubToHex(user)!, meta, getNDKRelayList) ) await Promise.all(promises) .then(() => { diff --git a/src/pages/verify/index.tsx b/src/pages/verify/index.tsx index 515a257..282390e 100644 --- a/src/pages/verify/index.tsx +++ b/src/pages/verify/index.tsx @@ -29,7 +29,7 @@ import styles from './style.module.scss' import { useLocation, useParams } from 'react-router-dom' import axios from 'axios' import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf.ts' -import { useAppSelector } from '../../hooks' +import { useAppSelector, useNDKContext } from '../../hooks' import { getLastSignersSig } from '../../utils/sign.ts' import { saveAs } from 'file-saver' import { Container } from '../../components/Container' @@ -166,6 +166,7 @@ const SlimPdfView = ({ export const VerifyPage = () => { const location = useLocation() const params = useParams() + const { getNDKRelayList } = useNDKContext() const usersAppData = useAppSelector((state) => state.userAppData) const usersPubkey = useAppSelector((state) => state.auth.usersPubkey) @@ -364,7 +365,7 @@ export const VerifyPage = () => { const users = Array.from(userSet) const promises = users.map((user) => - sendNotification(npubToHex(user)!, updatedMeta) + sendNotification(npubToHex(user)!, updatedMeta, getNDKRelayList) ) await Promise.all(promises) diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index 3775837..ed82513 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -18,11 +18,7 @@ import { } from 'nostr-tools' import { toast } from 'react-toastify' import { NIP05_REGEX } from '../constants' -import { - MetadataController, - NostrController, - relayController -} from '../controllers' +import { NostrController, relayController } from '../controllers' import { updateProcessedGiftWraps, updateUserAppData as updateUserAppDataAction @@ -35,7 +31,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' +import { Hexpubkey, NDKEvent, NDKRelayList } from '@nostr-dev-kit/ndk' /** * Generates a `d` tag for userAppData @@ -334,7 +330,9 @@ export const createWrap = (unsignedEvent: UnsignedEvent, receiver: string) => { * * @returns The user application data or null if an error occurs or no data is found. */ -export const getUsersAppData = async (): Promise => { +export const getUsersAppData = async ( + getNDKRelayList: (pubkey: Hexpubkey) => Promise +): Promise => { // Initialize an array to hold relay URLs const relays: string[] = [] @@ -344,27 +342,17 @@ export const getUsersAppData = async (): Promise => { // Check if relayMap is undefined in the Redux store if (!relayMap) { - // If relayMap is not present, fetch relay list metadata - const metadataController = MetadataController.getInstance() - const relaySet = await metadataController - .findRelayListMetadata(usersPubkey) - .catch((err) => { - // Log error and return null if fetching metadata fails - console.log( - `An error occurred while finding relay list metadata for ${hexToNpub(usersPubkey)}`, - err - ) - return null - }) + // If relayMap is not present, get relay list using NDKContext - // Return null if metadata retrieval failed - if (!relaySet) return null + const ndkRelayList = await getNDKRelayList(usersPubkey) // Ensure that the relay list is not empty - if (relaySet.write.length === 0) return null + if (ndkRelayList.writeRelayUrls.length === 0) return null // Add write relays to the relays array - relays.push(...relaySet.write) + relays.push(...ndkRelayList.writeRelayUrls) + + // // Ensure that the relay list is not empty } else { // If relayMap exists, filter and add write relays from the stored map const writeRelays = Object.keys(relayMap).filter( @@ -816,25 +804,14 @@ const getUserAppDataFromBlossom = async (url: string, privateKey: string) => { * @param pubkey - The public key to subscribe to. * @returns A promise that resolves when the subscription is successful. */ -export const subscribeForSigits = async (pubkey: string) => { - // Instantiate the MetadataController to retrieve relay list metadata - const metadataController = MetadataController.getInstance() - const relaySet = await metadataController - .findRelayListMetadata(pubkey) - .catch((err) => { - // Log an error if retrieving relay list metadata fails - console.log( - `An error occurred while finding relay list metadata for ${hexToNpub(pubkey)}`, - err - ) - return null - }) - - // Return if metadata retrieval failed - if (!relaySet) return +export const subscribeForSigits = async ( + pubkey: string, + getNDKRelayList: (pubkey: Hexpubkey) => Promise +) => { + const ndkRelayList = await getNDKRelayList(pubkey) // Ensure relay list is not empty - if (relaySet.read.length === 0) return + if (ndkRelayList.readRelayUrls.length === 0) return // Define the filter for the subscription const filter: Filter = { @@ -843,7 +820,9 @@ export const subscribeForSigits = async (pubkey: string) => { } // Process the received event synchronously - const events = await relayController.fetchEvents(filter, relaySet.read) + const events = await relayController.fetchEvents(filter, [ + ...ndkRelayList.readRelayUrls + ]) for (const e of events) { await processReceivedEvent(e) } @@ -908,7 +887,11 @@ const processReceivedEvent = async (event: Event, difficulty: number = 5) => { * @param receiver - The recipient's public key. * @param meta - Metadata associated with the notification. */ -export const sendNotification = async (receiver: string, meta: Meta) => { +export const sendNotification = async ( + receiver: string, + meta: Meta, + getNDKRelayList: (pubkey: Hexpubkey) => Promise +) => { // Retrieve the user's public key from the state const usersPubkey = store.getState().auth.usersPubkey! @@ -924,28 +907,14 @@ export const sendNotification = async (receiver: string, meta: Meta) => { // Wrap the unsigned event with the receiver's information const wrappedEvent = createWrap(unsignedEvent, receiver) - // Instantiate the MetadataController to retrieve relay list metadata - const metadataController = MetadataController.getInstance() - const relaySet = await metadataController - .findRelayListMetadata(receiver) - .catch((err) => { - // Log an error if retrieving relay list metadata fails - console.log( - `An error occurred while finding relay list metadata for ${hexToNpub(receiver)}`, - err - ) - return null - }) - - // Return if metadata retrieval failed - if (!relaySet) return + const ndkRelayList = await getNDKRelayList(receiver) // Ensure relay list is not empty - if (relaySet.read.length === 0) return + if (ndkRelayList.readRelayUrls.length === 0) return // Publish the notification event to the recipient's read relays await Promise.race([ - relayController.publish(wrappedEvent, relaySet.read), + relayController.publish(wrappedEvent, [...ndkRelayList.readRelayUrls]), timeout(40 * 1000) ]).catch((err) => { // Log an error if publishing the notification event fails -- 2.34.1 From 3615de70adf767388016895a448c49c86f846ad6 Mon Sep 17 00:00:00 2001 From: daniyal Date: Sun, 22 Dec 2024 21:49:56 +0500 Subject: [PATCH 06/16] chore: include sigit relay explicitly when fetching event from user relays --- src/contexts/NDKContext.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/contexts/NDKContext.tsx b/src/contexts/NDKContext.tsx index efc47e9..c4d73f9 100644 --- a/src/contexts/NDKContext.tsx +++ b/src/contexts/NDKContext.tsx @@ -150,6 +150,10 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => { return [] as string[] }) + if (!relayUrls.includes(SIGIT_RELAY)) { + relayUrls.push(SIGIT_RELAY) + } + return ndk .fetchEvents( filter, -- 2.34.1 From 0ea6ba003345e4a2bd89c1a9387ca7daa5c4f54e Mon Sep 17 00:00:00 2001 From: daniyal Date: Sun, 22 Dec 2024 21:53:00 +0500 Subject: [PATCH 07/16] chore: remove dvm utils file and use useDvm hook instead --- src/controllers/RelayController.ts | 64 --------- src/hooks/useAuth.ts | 31 +++-- src/utils/dvm.ts | 98 -------------- src/utils/relays.ts | 203 +++++------------------------ 4 files changed, 52 insertions(+), 344 deletions(-) delete mode 100644 src/utils/dvm.ts diff --git a/src/controllers/RelayController.ts b/src/controllers/RelayController.ts index df33b4b..ee66472 100644 --- a/src/controllers/RelayController.ts +++ b/src/controllers/RelayController.ts @@ -193,70 +193,6 @@ export class RelayController { return events[0] || null } - /** - * Subscribes to events from multiple relays. - * - * This method connects to the specified relay URLs and subscribes to events - * using the provided filter. It handles incoming events through the given - * `eventHandler` callback and manages the subscription lifecycle. - * - * @param filter - The filter criteria to apply when subscribing to events. - * @param relayUrls - An optional array of relay URLs to connect to. The default relay URL (`SIGIT_RELAY`) is added automatically. - * @param eventHandler - A callback function to handle incoming events. It receives an `Event` object. - * - */ - subscribeForEvents = async ( - filter: Filter, - relayUrls: string[] = [], - eventHandler: (event: Event) => void - ) => { - if (!relayUrls.includes(SIGIT_RELAY)) { - /** - * NOTE: To avoid side-effects on external relayUrls array passed as argument - * re-assigned relayUrls with added sigit relay instead of just appending to same array - */ - relayUrls = [...relayUrls, SIGIT_RELAY] // Add app relay to relays array if not exists already - } - - // connect to all specified relays - const relays = await settleAllFullfilfedPromises( - relayUrls, - this.connectRelay - ) - - // Check if any relays are connected - if (relays.length === 0) { - throw new Error('No relay is connected to fetch events!') - } - - const processedEvents: string[] = [] // To keep track of processed events - - // Create a promise for each relay subscription - const subPromises = relays.map((relay) => { - return new Promise((resolve) => { - // Subscribe to the relay with the specified filter - const sub = relay.subscribe([filter], { - // Handle incoming events - onevent: (e) => { - // Process event only if it hasn't been processed before - if (!processedEvents.includes(e.id)) { - processedEvents.push(e.id) - eventHandler(e) // Call the event handler with the event - } - }, - // Handle the End-Of-Stream (EOSE) message - oneose: () => { - sub.close() // Close the subscription - resolve() // Resolve the promise when EOSE is received - } - }) - }) - }) - - // Wait for all subscriptions to complete - await Promise.allSettled(subPromises) - } - publish = async ( event: Event, relayUrls: string[] = [] diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index 4ad0cd0..ff38a06 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -13,15 +13,17 @@ import { createAndSaveAuthToken, getAuthToken, getEmptyMetadataEvent, - getRelayMap, + getRelayMapFromNDKRelayList, unixNow } from '../utils' import { useAppDispatch, useAppSelector } from './store' import { useNDKContext } from './useNDKContext' +import { useDvm } from './useDvm' export const useAuth = () => { const dispatch = useAppDispatch() - const { findMetadata } = useNDKContext() + const { getRelayInfo } = useDvm() + const { findMetadata, getNDKRelayList } = useNDKContext() const { auth: authState, relays: relaysState } = useAppSelector( (state) => state @@ -101,23 +103,32 @@ export const useAuth = () => { }) ) - const relayMap = await getRelayMap(pubkey) + const ndkRelayList = await getNDKRelayList(pubkey) + const relays = ndkRelayList.relays - if (Object.keys(relayMap).length < 1) { + if (relays.length < 1) { // Navigate user to relays page if relay map is empty return appPrivateRoutes.relays } - if ( - authState.loggedIn && - !compareObjects(relaysState?.map, relayMap.map) - ) { - dispatch(setRelayMapAction(relayMap.map)) + getRelayInfo(relays) + + const relayMap = getRelayMapFromNDKRelayList(ndkRelayList) + + if (authState.loggedIn && !compareObjects(relaysState?.map, relayMap)) { + dispatch(setRelayMapAction(relayMap)) } return appPrivateRoutes.homePage }, - [dispatch, findMetadata, authState, relaysState] + [ + dispatch, + findMetadata, + getNDKRelayList, + getRelayInfo, + authState, + relaysState + ] ) return { diff --git a/src/utils/dvm.ts b/src/utils/dvm.ts deleted file mode 100644 index 24d637e..0000000 --- a/src/utils/dvm.ts +++ /dev/null @@ -1,98 +0,0 @@ -import NDK, { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk' -import { EventTemplate } from 'nostr-tools' -import { compareObjects, unixNow } from '.' -import { NostrController, relayController } from '../controllers' -import { setRelayInfoAction } from '../store/actions' -import store from '../store/store' -import { RelayInfoObject } from '../types' - -/** - * 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/relays.ts b/src/utils/relays.ts index bcbbad9..bcb2e98 100644 --- a/src/utils/relays.ts +++ b/src/utils/relays.ts @@ -1,172 +1,43 @@ -import { Event, Filter, kinds, UnsignedEvent } from 'nostr-tools' -import { RelayList } from 'nostr-tools/kinds' -import { getRelayInfo, unixNow } from '.' -import { NostrController, relayController } from '../controllers' -import { localCache } from '../services' -import { RelayMap, RelaySet } from '../types' -import { - DEFAULT_LOOK_UP_RELAY_LIST, - ONE_DAY_IN_MS, - ONE_WEEK_IN_MS, - SIGIT_RELAY -} from './const' -import NDK, { NDKEvent } from '@nostr-dev-kit/ndk' +import NDK, { NDKEvent, NDKRelayList } from '@nostr-dev-kit/ndk' +import { kinds, UnsignedEvent } from 'nostr-tools' +import { normalizeWebSocketURL, unixNow } from '.' +import { NostrController } from '../controllers' +import { RelayMap } from '../types' +import { SIGIT_RELAY } from './const' -const READ_MARKER = 'read' -const WRITE_MARKER = 'write' +export const getRelayMapFromNDKRelayList = (ndkRelayList: NDKRelayList) => { + const relayMap: RelayMap = {} -/** - * Attempts to find a relay list from the provided lookUpRelays. - * If the relay list is found, it will be added to the user relay list metadata. - * @param lookUpRelays - * @param hexKey - * @return found relay list or null - */ -const findRelayListAndUpdateCache = async ( - lookUpRelays: string[], - hexKey: string -): Promise => { - try { - const eventFilter: Filter = { - kinds: [RelayList], - authors: [hexKey] + ndkRelayList.readRelayUrls.forEach((relayUrl) => { + const normalizedUrl = normalizeWebSocketURL(relayUrl) + + relayMap[normalizedUrl] = { + read: true, + write: false } + }) - const event = await relayController.fetchEvent(eventFilter, lookUpRelays) - if (event) { - await localCache.addUserRelayListMetadata(event) + ndkRelayList.writeRelayUrls.forEach((relayUrl) => { + const normalizedUrl = normalizeWebSocketURL(relayUrl) + + const existing = relayMap[normalizedUrl] + if (existing) { + existing.write = true + } else { + relayMap[normalizedUrl] = { + read: false, + write: true + } } - return event - } catch (error) { - console.error(error) - return null - } + }) + + return relayMap } -/** - * Attempts to find a relay list in cache. If it is present, it will check that the cached event is not - * older than one week. - * @param hexKey - * @return RelayList event if it's not older than a week; otherwise null - */ -const findRelayListInCache = async (hexKey: string): Promise => { - try { - // Attempt to retrieve the metadata event from the local cache - const cachedRelayListMetadataEvent = - await localCache.getUserRelayListMetadata(hexKey) - - // Check if the cached event is not older than one week - if ( - cachedRelayListMetadataEvent && - !isOlderThanOneWeek(cachedRelayListMetadataEvent.cachedAt) - ) { - return cachedRelayListMetadataEvent.event - } - - return null - } catch (error) { - console.error(error) - return null - } -} - -/** - * Transforms a list of relay tags from a Nostr Event to a RelaySet. - * @param tags - */ -const getUserRelaySet = (tags: string[][]): RelaySet => { - return tags - .filter(isRelayTag) - .reduce(toRelaySet, getDefaultRelaySet()) -} - -const getDefaultRelaySet = (): RelaySet => ({ - read: [SIGIT_RELAY], - write: [SIGIT_RELAY] -}) - -const getDefaultRelayMap = (): RelayMap => ({ +export const getDefaultRelayMap = (): RelayMap => ({ [SIGIT_RELAY]: { write: true, read: true } }) -const isOlderThanOneWeek = (cachedAt: number) => { - return Date.now() - cachedAt > ONE_WEEK_IN_MS -} - -const isOlderThanOneDay = (cachedAt: number) => { - return Date.now() - cachedAt > ONE_DAY_IN_MS -} - -const isRelayTag = (tag: string[]): boolean => tag[0] === 'r' - -const toRelaySet = (obj: RelaySet, tag: string[]): RelaySet => { - if (tag.length >= 3) { - const marker = tag[2] - - if (marker === READ_MARKER) { - obj.read.push(tag[1]) - } else if (marker === WRITE_MARKER) { - obj.write.push(tag[1]) - } - } - if (tag.length === 2) { - obj.read.push(tag[1]) - obj.write.push(tag[1]) - } - - return obj -} - -/** - * 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 }> => { - // 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, DEFAULT_LOOK_UP_RELAY_LIST) - .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. @@ -174,7 +45,7 @@ const getRelayMap = async ( * @param extraRelaysToPublish - optional relays to publish relay map. * @returns - promise that resolves into a string representing publishing result. */ -const publishRelayMap = async ( +export const publishRelayMap = async ( relayMap: RelayMap, npub: string, ndk: NDK, @@ -218,15 +89,3 @@ const publishRelayMap = async ( return Promise.reject('Publishing updated relay map was unsuccessful.') } - -export { - findRelayListAndUpdateCache, - findRelayListInCache, - getDefaultRelayMap, - getDefaultRelaySet, - getRelayMap, - getUserRelaySet, - isOlderThanOneDay, - isOlderThanOneWeek, - publishRelayMap -} -- 2.34.1 From e8a53bc73e87d56f60941935be7f758b66526185 Mon Sep 17 00:00:00 2001 From: daniyal Date: Sun, 22 Dec 2024 23:38:27 +0500 Subject: [PATCH 08/16] chore: removed relay controller in favor of NDKContext --- src/contexts/NDKContext.tsx | 3 + src/controllers/RelayController.ts | 242 --------------- src/controllers/index.ts | 1 - src/hooks/index.ts | 1 + src/hooks/useNDK.ts | 415 +++++++++++++++++++++++++ src/layouts/Main.tsx | 17 +- src/pages/create/index.tsx | 51 ++- src/pages/settings/relays/index.tsx | 34 +- src/pages/sign/index.tsx | 8 +- src/pages/verify/index.tsx | 10 +- src/utils/nostr.ts | 462 +--------------------------- 11 files changed, 471 insertions(+), 773 deletions(-) delete mode 100644 src/controllers/RelayController.ts create mode 100644 src/hooks/useNDK.ts diff --git a/src/contexts/NDKContext.tsx b/src/contexts/NDKContext.tsx index c4d73f9..61f856c 100644 --- a/src/contexts/NDKContext.tsx +++ b/src/contexts/NDKContext.tsx @@ -244,6 +244,9 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => { let ndkRelaySet: NDKRelaySet | undefined if (explicitRelayUrls && explicitRelayUrls.length > 0) { + if (!explicitRelayUrls.includes(SIGIT_RELAY)) { + explicitRelayUrls = [...explicitRelayUrls, SIGIT_RELAY] + } ndkRelaySet = NDKRelaySet.fromRelayUrls(explicitRelayUrls, ndk) } diff --git a/src/controllers/RelayController.ts b/src/controllers/RelayController.ts deleted file mode 100644 index ee66472..0000000 --- a/src/controllers/RelayController.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { Event, Filter, Relay } from 'nostr-tools' -import { - settleAllFullfilfedPromises, - normalizeWebSocketURL, - timeout -} from '../utils' -import { SIGIT_RELAY } from '../utils/const' - -/** - * Singleton class to manage relay operations. - */ -export class RelayController { - private static instance: RelayController - private pendingConnections = new Map>() // Track pending connections - public connectedRelays = new Map() - - private constructor() {} - - /** - * Provides the singleton instance of RelayController. - * - * @returns The singleton instance of RelayController. - */ - public static getInstance(): RelayController { - if (!RelayController.instance) { - RelayController.instance = new RelayController() - } - return RelayController.instance - } - - /** - * Connects to a relay server if not already connected. - * - * This method checks if a relay with the given URL is already in the list of connected relays. - * If it is not connected, it attempts to establish a new connection. - * On successful connection, the relay is added to the list of connected relays and returned. - * If the connection fails, an error is logged and `null` is returned. - * - * @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): Promise => { - const normalizedWebSocketURL = normalizeWebSocketURL(relayUrl) - const relay = this.connectedRelays.get(normalizedWebSocketURL) - - if (relay) { - 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) - } - - // Check if there's already a pending connection for this relay URL - if (this.pendingConnections.has(relayUrl)) { - // Return the existing promise to avoid making another connection - return this.pendingConnections.get(relayUrl)! - } - - // Create a new connection promise and store it in pendingConnections - const connectionPromise = Relay.connect(relayUrl) - .then((relay) => { - if (relay.connected) { - // Add the newly connected relay to the connected relays map - this.connectedRelays.set(relayUrl, relay) - - // Return the newly connected relay - return relay - } - - 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 - }) - .finally(() => { - // Remove the connection from pendingConnections once it settles - this.pendingConnections.delete(relayUrl) - }) - - this.pendingConnections.set(relayUrl, connectionPromise) - return connectionPromise - } - - /** - * Asynchronously retrieves multiple event from a set of relays based on a provided filter. - * If no relays are specified, it defaults to using connected relays. - * - * @param filter - The filter criteria to find the event. - * @param relays - An optional array of relay URLs to search for the event. - * @returns Returns a promise that resolves with an array of events. - */ - fetchEvents = async ( - filter: Filter, - relayUrls: string[] = [] - ): Promise => { - if (!relayUrls.includes(SIGIT_RELAY)) { - /** - * NOTE: To avoid side-effects on external relayUrls array passed as argument - * re-assigned relayUrls with added sigit relay instead of just appending to same array - */ - - relayUrls = [...relayUrls, SIGIT_RELAY] // Add app relay to relays array if not exists already - } - - // connect to all specified relays - const relays = await settleAllFullfilfedPromises( - relayUrls, - this.connectRelay - ) - - // Check if any relays are connected - if (relays.length === 0) { - throw new Error('No relay is connected to fetch events!') - } - - const events: Event[] = [] - const eventIds = new Set() // To keep track of event IDs and avoid duplicates - - // 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 resolve() - } - - // Subscribe to the relay with the specified filter - const sub = relay.subscribe([filter], { - // Handle incoming events - onevent: (e) => { - // Add the event to the array if it's not a duplicate - if (!eventIds.has(e.id)) { - eventIds.add(e.id) // Record the event ID - events.push(e) // Add the event to the array - } - }, - // Handle the End-Of-Stream (EOSE) message - oneose: () => { - sub.close() // Close the subscription - resolve() // Resolve the promise when EOSE is received - } - }) - - // add a 30 sec of timeout to subscription - setTimeout(() => { - if (!sub.closed) { - sub.close() - resolve() - } - }, 30 * 1000) - }) - }) - - // Wait for all subscriptions to complete - await Promise.allSettled(subPromises) - - // It is possible that different relays will send different events and events array may contain more events then specified limit in filter - // To fix this issue we'll first sort these events and then return only limited events - if (filter.limit) { - // Sort events by creation date in descending order - events.sort((a, b) => b.created_at - a.created_at) - - return events.slice(0, filter.limit) - } - - return events - } - - /** - * Asynchronously retrieves an event from a set of relays based on a provided filter. - * If no relays are specified, it defaults to using connected relays. - * - * @param filter - The filter criteria to find the event. - * @param relays - An optional array of relay URLs to search for the event. - * @returns Returns a promise that resolves to the found event or null if not found. - */ - fetchEvent = async ( - filter: Filter, - relays: string[] = [] - ): Promise => { - const events = await this.fetchEvents(filter, relays) - - // Sort events by creation date in descending order - events.sort((a, b) => b.created_at - a.created_at) - - // Return the most recent event, or null if no events were received - return events[0] || null - } - - publish = async ( - event: Event, - relayUrls: string[] = [] - ): Promise => { - if (!relayUrls.includes(SIGIT_RELAY)) { - /** - * NOTE: To avoid side-effects on external relayUrls array passed as argument - * re-assigned relayUrls with added sigit relay instead of just appending to same array - */ - relayUrls = [...relayUrls, SIGIT_RELAY] // Add app relay to relays array if not exists already - } - - // connect to all specified relays - const relays = await settleAllFullfilfedPromises( - relayUrls, - this.connectRelay - ) - - // Check if any relays are connected - if (relays.length === 0) { - throw new Error('No relay is connected to publish event!') - } - - const publishedOnRelays: string[] = [] // List to track which relays successfully published the event - - // Create a promise for publishing the event to each connected relay - const publishPromises = relays.map(async (relay) => { - try { - await Promise.race([ - relay.publish(event), // Publish the event to the relay - 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.url}`, err) - } - }) - - // Wait for all publish operations to complete (either fulfilled or rejected) - await Promise.allSettled(publishPromises) - - // Return the list of relay URLs where the event was published - return publishedOnRelays - } -} - -export const relayController = RelayController.getInstance() diff --git a/src/controllers/index.ts b/src/controllers/index.ts index 63c9671..e7302ce 100644 --- a/src/controllers/index.ts +++ b/src/controllers/index.ts @@ -1,2 +1 @@ export * from './NostrController' -export * from './RelayController' diff --git a/src/hooks/index.ts b/src/hooks/index.ts index e0c77b0..cbadfee 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -3,4 +3,5 @@ export * from './useAuth' export * from './useDidMount' export * from './useDvm' export * from './useLogout' +export * from './useNDK' export * from './useNDKContext' diff --git a/src/hooks/useNDK.ts b/src/hooks/useNDK.ts new file mode 100644 index 0000000..5e24352 --- /dev/null +++ b/src/hooks/useNDK.ts @@ -0,0 +1,415 @@ +import { useCallback } from 'react' +import { toast } from 'react-toastify' + +import { bytesToHex } from '@noble/hashes/utils' +import { NDKEvent, NDKFilter, NDKKind } from '@nostr-dev-kit/ndk' +import _ from 'lodash' +import { + Event, + generateSecretKey, + getPublicKey, + kinds, + UnsignedEvent +} from 'nostr-tools' + +import { useAppDispatch, useAppSelector, useNDKContext } from '.' +import { NostrController } from '../controllers' +import { + updateProcessedGiftWraps, + updateUserAppData as updateUserAppDataAction +} from '../store/actions' +import { Keys } from '../store/auth/types' +import { Meta, UserAppData, UserRelaysType } from '../types' +import { + countLeadingZeroes, + createWrap, + deleteBlossomFile, + getDTagForUserAppData, + getUserAppDataFromBlossom, + hexToNpub, + parseJson, + unixNow, + uploadUserAppDataToBlossom +} from '../utils' + +export const useNDK = () => { + const dispatch = useAppDispatch() + const { ndk, fetchEvent, fetchEventsFromUserRelays, publish } = + useNDKContext() + const usersPubkey = useAppSelector((state) => state.auth.usersPubkey) + const appData = useAppSelector((state) => state.userAppData) + const processedEvents = useAppSelector( + (state) => state.userAppData?.processedGiftWraps + ) + + /** + * Fetches user application data based on user's public key. + * + * @returns The user application data or null if an error occurs or no data is found. + */ + const getUsersAppData = useCallback(async (): Promise => { + if (!usersPubkey) return null + + // Generate an identifier for the user's nip78 + const dTag = await getDTagForUserAppData() + if (!dTag) return null + + // Define a filter for fetching events + const filter: NDKFilter = { + kinds: [NDKKind.AppSpecificData], + authors: [usersPubkey], + '#d': [dTag] + } + + const encryptedContent = await fetchEvent(filter) + .then((event) => { + if (event) return event.content + + // If no event is found, return an empty stringified object + return '{}' + }) + .catch((err) => { + // Log error and show a toast notification if fetching event fails + console.log(`An error occurred in finding kind 30078 event`, err) + toast.error( + 'An error occurred in finding kind 30078 event for data storage' + ) + return null + }) + + // Return null if encrypted content retrieval fails + if (!encryptedContent) return null + + // Handle case where the encrypted content is an empty object + if (encryptedContent === '{}') { + // Generate ephemeral key pair + const secret = generateSecretKey() + const pubKey = getPublicKey(secret) + + return { + sigits: {}, + processedGiftWraps: [], + blossomUrls: [], + keyPair: { + private: bytesToHex(secret), + public: pubKey + } + } + } + + // Get an instance of the NostrController + const nostrController = NostrController.getInstance() + + // Decrypt the encrypted content + const decrypted = await nostrController + .nip04Decrypt(usersPubkey, encryptedContent) + .catch((err) => { + // Log error and show a toast notification if decryption fails + console.log('An error occurred while decrypting app data', err) + toast.error('An error occurred while decrypting app data') + return null + }) + + // Return null if decryption fails + if (!decrypted) return null + + // Parse the decrypted content + const parsedContent = await parseJson<{ + blossomUrls: string[] + keyPair: Keys + }>(decrypted).catch((err) => { + // Log error and show a toast notification if parsing fails + console.log( + 'An error occurred in parsing the content of kind 30078 event', + err + ) + toast.error( + 'An error occurred in parsing the content of kind 30078 event' + ) + return null + }) + + // Return null if parsing fails + if (!parsedContent) return null + + const { blossomUrls, keyPair } = parsedContent + + // Return null if no blossom URLs are found + if (blossomUrls.length === 0) return null + + // Fetch additional user app data from the first blossom URL + const dataFromBlossom = await getUserAppDataFromBlossom( + blossomUrls[0], + keyPair.private + ) + + // Return null if fetching data from blossom fails + if (!dataFromBlossom) return null + + const { sigits, processedGiftWraps } = dataFromBlossom + + // Return the final user application data + return { + blossomUrls, + keyPair, + sigits, + processedGiftWraps + } + }, [usersPubkey, fetchEvent]) + + const updateUsersAppData = useCallback( + async (meta: Meta) => { + if (!appData || !appData.keyPair || !usersPubkey) return null + + const sigits = _.cloneDeep(appData.sigits) + + const createSignatureEvent = await parseJson( + meta.createSignature + ).catch((err) => { + console.log('err in parsing the createSignature event:>> ', err) + toast.error( + err.message || 'error occurred in parsing the create signature event' + ) + return null + }) + + if (!createSignatureEvent) return null + + const id = createSignatureEvent.id + let isUpdated = false + + // check if sigit already exists + if (id in sigits) { + // update meta only if incoming meta is more recent + // than already existing one + const existingMeta = sigits[id] + if (existingMeta.modifiedAt < meta.modifiedAt) { + sigits[id] = meta + isUpdated = true + } + } else { + sigits[id] = meta + isUpdated = true + } + + if (!isUpdated) return null + + const blossomUrls = [...appData.blossomUrls] + + const newBlossomUrl = await uploadUserAppDataToBlossom( + sigits, + appData.processedGiftWraps, + appData.keyPair.private + ).catch((err) => { + console.log( + 'An error occurred in uploading user app data file to blossom server', + err + ) + toast.error( + 'An error occurred in uploading user app data file to blossom server' + ) + return null + }) + + if (!newBlossomUrl) return null + + // insert new blossom url at the start of the array + blossomUrls.unshift(newBlossomUrl) + + // only keep last 10 blossom urls, delete older ones + if (blossomUrls.length > 10) { + const filesToDelete = blossomUrls.splice(10) + filesToDelete.forEach((url) => { + deleteBlossomFile(url, appData.keyPair!.private).catch((err) => { + console.log( + 'An error occurred in removing old file of user app data from blossom server', + err + ) + }) + }) + } + + // encrypt content for storing in kind 30078 event + const nostrController = NostrController.getInstance() + const encryptedContent = await nostrController + .nip04Encrypt( + usersPubkey, + JSON.stringify({ + blossomUrls, + keyPair: appData.keyPair + }) + ) + .catch((err) => { + console.log( + 'An error occurred in encryption of content for app data', + err + ) + toast.error( + err.message || + 'An error occurred in encryption of content for app data' + ) + return null + }) + + if (!encryptedContent) return null + + // generate the identifier for user's appData event + const dTag = await getDTagForUserAppData() + if (!dTag) return null + + const updatedEvent: UnsignedEvent = { + kind: kinds.Application, + pubkey: usersPubkey, + created_at: unixNow(), + tags: [['d', dTag]], + content: encryptedContent + } + + const signedEvent = await nostrController + .signEvent(updatedEvent) + .catch((err) => { + console.log('An error occurred in signing event', err) + toast.error(err.message || 'An error occurred in signing event') + return null + }) + + if (!signedEvent) return null + + const ndkEvent = new NDKEvent(ndk, signedEvent) + const publishResult = await publish(ndkEvent) + + if (publishResult.length === 0) { + toast.error( + 'An unexpected error occurred in publishing updated app data ' + ) + return null + } + + if (!publishResult) return null + + // update redux store + dispatch( + updateUserAppDataAction({ + sigits, + blossomUrls, + processedGiftWraps: [...appData.processedGiftWraps], + keyPair: { + ...appData.keyPair + } + }) + ) + + return signedEvent + }, + [appData, dispatch, ndk, publish, usersPubkey] + ) + + const processReceivedEvent = useCallback( + async (event: NDKEvent, difficulty: number = 5) => { + // Abort processing if userAppData is undefined + if (!processedEvents) return + + if (processedEvents.includes(event.id)) return + + dispatch(updateProcessedGiftWraps([...processedEvents, event.id])) + + // validate PoW + // Count the number of leading zero bits in the hash + const leadingZeroes = countLeadingZeroes(event.id) + if (leadingZeroes < difficulty) return + + // decrypt the content of gift wrap event + const nostrController = NostrController.getInstance() + const decrypted = await nostrController.nip44Decrypt( + event.pubkey, + event.content + ) + + const internalUnsignedEvent = await parseJson( + decrypted + ).catch((err) => { + console.log( + 'An error occurred in parsing the internal unsigned event', + err + ) + return null + }) + + if (!internalUnsignedEvent || internalUnsignedEvent.kind !== 938) return + + const meta = await parseJson(internalUnsignedEvent.content).catch( + (err) => { + console.log( + 'An error occurred in parsing the internal unsigned event', + err + ) + return null + } + ) + + if (!meta) return + + await updateUsersAppData(meta) + }, + [dispatch, processedEvents, updateUsersAppData] + ) + + const subscribeForSigits = useCallback( + async (pubkey: string) => { + // Define the filter for the subscription + const filter: NDKFilter = { + kinds: [1059 as NDKKind], + '#p': [pubkey] + } + + // Process the received event synchronously + const events = await fetchEventsFromUserRelays( + filter, + pubkey, + UserRelaysType.Read + ) + + for (const e of events) { + await processReceivedEvent(e) + } + }, + [fetchEventsFromUserRelays, processReceivedEvent] + ) + + const sendNotification = useCallback( + async (receiver: string, meta: Meta) => { + if (!usersPubkey) return + + // Create an unsigned event object with the provided metadata + const unsignedEvent: UnsignedEvent = { + kind: 938, + pubkey: usersPubkey, + content: JSON.stringify(meta), + tags: [], + created_at: unixNow() + } + + // Wrap the unsigned event with the receiver's information + const wrappedEvent = createWrap(unsignedEvent, receiver) + + // Publish the notification event to the recipient's read relays + const ndkEvent = new NDKEvent(ndk, wrappedEvent) + await publish(ndkEvent).catch((err) => { + // Log an error if publishing the notification event fails + console.log( + `An error occurred while publishing notification event for ${hexToNpub(receiver)}`, + err + ) + throw err + }) + }, + [ndk, publish, usersPubkey] + ) + + return { + getUsersAppData, + subscribeForSigits, + updateUsersAppData, + sendNotification + } +} diff --git a/src/layouts/Main.tsx b/src/layouts/Main.tsx index 4409224..6d27ae8 100644 --- a/src/layouts/Main.tsx +++ b/src/layouts/Main.tsx @@ -16,6 +16,7 @@ import { useAppSelector, useAuth, useLogout, + useNDK, useNDKContext } from '../hooks' @@ -30,12 +31,7 @@ import { import { LoginMethod } from '../store/auth/types' import { setUserRobotImage } from '../store/userRobotImage/action' -import { - getRoboHashPicture, - getUsersAppData, - loadState, - subscribeForSigits -} from '../utils' +import { getRoboHashPicture, loadState } from '../utils' import styles from './style.module.scss' @@ -44,8 +40,9 @@ export const MainLayout = () => { const navigate = useNavigate() const dispatch = useAppDispatch() const logout = useLogout() - const { findMetadata, getNDKRelayList } = useNDKContext() + const { findMetadata } = useNDKContext() const { authAndGetMetadataAndRelaysMap } = useAuth() + const { getUsersAppData, subscribeForSigits } = useNDK() const [isLoading, setIsLoading] = useState(true) const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState(`Loading App`) @@ -191,13 +188,13 @@ export const MainLayout = () => { if (pubkey && !hasSubscribed.current) { // Call `subscribeForSigits` only if it hasn't been called before // #193 disabled websocket subscribtion, until #194 is done - subscribeForSigits(pubkey, getNDKRelayList) + subscribeForSigits(pubkey) // Mark `subscribeForSigits` as called hasSubscribed.current = true } } - }, [authState, isLoggedIn, usersAppData, getNDKRelayList]) + }, [authState, isLoggedIn, usersAppData, subscribeForSigits]) /** * When authState change user logged in / or app reloaded @@ -214,7 +211,7 @@ export const MainLayout = () => { setIsLoading(true) setLoadingSpinnerDesc(`Loading SIGit history...`) - getUsersAppData(getNDKRelayList) + getUsersAppData() .then((appData) => { if (appData) { dispatch(updateUserAppData(appData)) diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index b2d6f5d..f593cbd 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -10,7 +10,6 @@ import { import type { Identifier, XYCoord } from 'dnd-core' import saveAs from 'file-saver' import JSZip from 'jszip' -import { Event } from 'nostr-tools' import { useCallback, useEffect, useRef, useState } from 'react' import { DndProvider, useDrag, useDrop } from 'react-dnd' import { MultiBackend } from 'react-dnd-multi-backend' @@ -20,7 +19,7 @@ import { useLocation, useNavigate } from 'react-router-dom' import { toast } from 'react-toastify' import { LoadingSpinner } from '../../components/LoadingSpinner' import { UserAvatar } from '../../components/UserAvatar' -import { NostrController, RelayController } from '../../controllers' +import { NostrController } from '../../controllers' import { appPrivateRoutes } from '../../routes' import { CreateSignatureEventContent, @@ -28,6 +27,7 @@ import { Meta, SignedEvent, User, + UserRelaysType, UserRole } from '../../types' import { @@ -42,9 +42,7 @@ import { unixNow, npubToHex, queryNip05, - sendNotification, signEventForMetaFile, - updateUsersAppData, uploadToFileStorage, DEFAULT_TOOLBOX, settleAllFullfilfedPromises @@ -75,15 +73,17 @@ import { Autocomplete } from '@mui/lab' import _, { truncate } from 'lodash' import * as React from 'react' import { AvatarIconButton } from '../../components/UserAvatarIconButton' -import { NDKUserProfile } from '@nostr-dev-kit/ndk' +import { NDKUserProfile, NostrEvent } from '@nostr-dev-kit/ndk' import { useNDKContext } from '../../hooks/useNDKContext.ts' +import { useNDK } from '../../hooks/useNDK.ts' -type FoundUser = Event & { npub: string } +type FoundUser = NostrEvent & { npub: string } export const CreatePage = () => { const navigate = useNavigate() const location = useLocation() - const { findMetadata, getNDKRelayList } = useNDKContext() + const { findMetadata, fetchEventsFromUserRelays } = useNDKContext() + const { updateUsersAppData, sendNotification } = useNDK() const { uploadedFiles } = location.state || {} const [currentFile, setCurrentFile] = useState() @@ -155,24 +155,20 @@ export const CreatePage = () => { setSearchUsersLoading(true) - const relayController = RelayController.getInstance() - const searchTerm = searchString.trim() - const ndkRelayList = await getNDKRelayList(usersPubkey) - - relayController - .fetchEvents( - { - kinds: [0], - search: searchTerm - }, - [...ndkRelayList.writeRelayUrls] - ) + fetchEventsFromUserRelays( + { + kinds: [0], + search: searchTerm + }, + usersPubkey, + UserRelaysType.Write + ) .then((events) => { - console.log('events', events) + const nostrEvents = events.map((event) => event.rawEvent()) - const fineFilteredEvents: FoundUser[] = events + const fineFilteredEvents = nostrEvents .filter((event) => { const lowercaseContent = event.content.toLowerCase() @@ -189,15 +185,15 @@ export const CreatePage = () => { lowercaseContent.includes(`"nip05":"${searchTerm.toLowerCase()}"`) ) }) - .reduce((uniqueEvents: FoundUser[], event: Event) => { - if (!uniqueEvents.some((e: Event) => e.pubkey === event.pubkey)) { + .reduce((uniqueEvents, event) => { + if (!uniqueEvents.some((e) => e.pubkey === event.pubkey)) { uniqueEvents.push({ ...event, npub: hexToNpub(event.pubkey) }) } return uniqueEvents - }, []) + }, [] as FoundUser[]) console.log('fineFilteredEvents', fineFilteredEvents) setFoundUsers(fineFilteredEvents) @@ -773,9 +769,7 @@ export const CreatePage = () => { : viewers.map((viewer) => viewer.pubkey) ).filter((receiver) => receiver !== usersPubkey) - return receivers.map((receiver) => - sendNotification(receiver, meta, getNDKRelayList) - ) + return receivers.map((receiver) => sendNotification(receiver, meta)) } const extractNostrId = (stringifiedEvent: string): string => { @@ -965,12 +959,11 @@ export const CreatePage = () => { setUserSearchInput(value) } - const parseContent = (event: Event) => { + const parseContent = (event: NostrEvent) => { try { return JSON.parse(event.content) } catch (e) { return undefined - console.error(e) } } diff --git a/src/pages/settings/relays/index.tsx b/src/pages/settings/relays/index.tsx index ba776fb..b2c102e 100644 --- a/src/pages/settings/relays/index.tsx +++ b/src/pages/settings/relays/index.tsx @@ -21,17 +21,12 @@ import { useNDKContext } from '../../../hooks' import { setRelayMapAction } from '../../../store/actions' -import { - RelayConnectionState, - RelayFee, - RelayInfo, - RelayMap -} from '../../../types' +import { RelayConnectionState, RelayFee, RelayInfo } from '../../../types' import { capitalizeFirstLetter, compareObjects, + getRelayMapFromNDKRelayList, hexToNpub, - normalizeWebSocketURL, publishRelayMap, shorten, timeout @@ -85,30 +80,7 @@ export const RelaysPage = () => { // 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 - } - } - }) + const newRelayMap = getRelayMapFromNDKRelayList(ndkRelayList) if (!compareObjects(relayMap, newRelayMap)) { dispatch(setRelayMapAction(newRelayMap)) diff --git a/src/pages/sign/index.tsx b/src/pages/sign/index.tsx index f874122..3adea40 100644 --- a/src/pages/sign/index.tsx +++ b/src/pages/sign/index.tsx @@ -29,9 +29,7 @@ import { npubToHex, parseJson, readContentOfZipEntry, - sendNotification, signEventForMetaFile, - updateUsersAppData, findOtherUserMarks, timeout, processMarks @@ -56,7 +54,7 @@ import { import { ARRAY_BUFFER, DEFLATE } from '../../utils/const.ts' import { generateTimestamp } from '../../utils/opentimestamps.ts' import { MARK_TYPE_CONFIG } from '../../components/MarkTypeStrategy/MarkStrategy.tsx' -import { useNDKContext } from '../../hooks/useNDKContext.ts' +import { useNDK } from '../../hooks/useNDK.ts' enum SignedStatus { Fully_Signed, @@ -68,7 +66,7 @@ export const SignPage = () => { const navigate = useNavigate() const location = useLocation() const params = useParams() - const { getNDKRelayList } = useNDKContext() + const { updateUsersAppData, sendNotification } = useNDK() const usersAppData = useAppSelector((state) => state.userAppData) @@ -783,7 +781,7 @@ export const SignPage = () => { setLoadingSpinnerDesc('Sending notifications') const users = Array.from(userSet) const promises = users.map((user) => - sendNotification(npubToHex(user)!, meta, getNDKRelayList) + sendNotification(npubToHex(user)!, meta) ) await Promise.all(promises) .then(() => { diff --git a/src/pages/verify/index.tsx b/src/pages/verify/index.tsx index 282390e..6dc5180 100644 --- a/src/pages/verify/index.tsx +++ b/src/pages/verify/index.tsx @@ -21,15 +21,13 @@ import { readContentOfZipEntry, signEventForMetaFile, getCurrentUserFiles, - updateUsersAppData, - npubToHex, - sendNotification + npubToHex } from '../../utils' import styles from './style.module.scss' import { useLocation, useParams } from 'react-router-dom' import axios from 'axios' import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf.ts' -import { useAppSelector, useNDKContext } from '../../hooks' +import { useAppSelector, useNDK } from '../../hooks' import { getLastSignersSig } from '../../utils/sign.ts' import { saveAs } from 'file-saver' import { Container } from '../../components/Container' @@ -166,7 +164,7 @@ const SlimPdfView = ({ export const VerifyPage = () => { const location = useLocation() const params = useParams() - const { getNDKRelayList } = useNDKContext() + const { updateUsersAppData, sendNotification } = useNDK() const usersAppData = useAppSelector((state) => state.userAppData) const usersPubkey = useAppSelector((state) => state.auth.usersPubkey) @@ -365,7 +363,7 @@ export const VerifyPage = () => { const users = Array.from(userSet) const promises = users.map((user) => - sendNotification(npubToHex(user)!, updatedMeta, getNDKRelayList) + sendNotification(npubToHex(user)!, updatedMeta) ) await Promise.all(promises) diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index ed82513..ad33ab8 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -1,16 +1,15 @@ -import { bytesToHex, hexToBytes } from '@noble/hashes/utils' +import { hexToBytes } from '@noble/hashes/utils' +import { NDKEvent } from '@nostr-dev-kit/ndk' import axios from 'axios' -import _, { truncate } from 'lodash' +import { truncate } from 'lodash' import { Event, EventTemplate, - Filter, UnsignedEvent, finalizeEvent, generateSecretKey, getEventHash, getPublicKey, - kinds, nip04, nip19, nip44, @@ -18,25 +17,16 @@ import { } from 'nostr-tools' import { toast } from 'react-toastify' import { NIP05_REGEX } from '../constants' -import { NostrController, relayController } from '../controllers' -import { - updateProcessedGiftWraps, - updateUserAppData as updateUserAppDataAction -} from '../store/actions' -import { Keys } from '../store/auth/types' import store from '../store/store' -import { Meta, ProfileMetadata, SignedEvent, UserAppData } from '../types' -import { getDefaultRelayMap } from './relays' -import { parseJson, removeLeadingSlash } from './string' -import { timeout } from './utils' -import { getHash } from './hash' +import { Meta, ProfileMetadata, SignedEvent } from '../types' import { SIGIT_BLOSSOM } from './const.ts' -import { Hexpubkey, NDKEvent, NDKRelayList } from '@nostr-dev-kit/ndk' +import { getHash } from './hash' +import { parseJson, removeLeadingSlash } from './string' /** * Generates a `d` tag for userAppData */ -const getDTagForUserAppData = async (): Promise => { +export const getDTagForUserAppData = async (): Promise => { const isLoggedIn = store.getState().auth.loggedIn const pubkey = store.getState().auth?.usersPubkey @@ -325,309 +315,7 @@ export const createWrap = (unsignedEvent: UnsignedEvent, receiver: string) => { } } -/** - * Fetches user application data based on user's public key and stored metadata. - * - * @returns The user application data or null if an error occurs or no data is found. - */ -export const getUsersAppData = async ( - getNDKRelayList: (pubkey: Hexpubkey) => Promise -): Promise => { - // Initialize an array to hold relay URLs - const relays: string[] = [] - - // Retrieve the user's public key and relay map from the Redux store - const usersPubkey = store.getState().auth.usersPubkey! - const relayMap = store.getState().relays?.map - - // Check if relayMap is undefined in the Redux store - if (!relayMap) { - // If relayMap is not present, get relay list using NDKContext - - const ndkRelayList = await getNDKRelayList(usersPubkey) - - // Ensure that the relay list is not empty - if (ndkRelayList.writeRelayUrls.length === 0) return null - - // Add write relays to the relays array - relays.push(...ndkRelayList.writeRelayUrls) - - // // Ensure that the relay list is not empty - } else { - // If relayMap exists, filter and add write relays from the stored map - const writeRelays = Object.keys(relayMap).filter( - (key) => relayMap[key].write - ) - relays.push(...writeRelays) - } - - // Generate an identifier for the user's nip78 - const dTag = await getDTagForUserAppData() - if (!dTag) return null - - // Define a filter for fetching events - const filter: Filter = { - kinds: [kinds.Application], - '#d': [dTag] - } - - const encryptedContent = await relayController - .fetchEvent(filter, relays) - .then((event) => { - if (event) return event.content - - // If no event is found, return an empty stringified object - return '{}' - }) - .catch((err) => { - // Log error and show a toast notification if fetching event fails - console.log(`An error occurred in finding kind 30078 event`, err) - toast.error( - 'An error occurred in finding kind 30078 event for data storage' - ) - return null - }) - - // Return null if encrypted content retrieval fails - if (!encryptedContent) return null - - // Handle case where the encrypted content is an empty object - if (encryptedContent === '{}') { - // Generate ephemeral key pair - const secret = generateSecretKey() - const pubKey = getPublicKey(secret) - - return { - sigits: {}, - processedGiftWraps: [], - blossomUrls: [], - keyPair: { - private: bytesToHex(secret), - public: pubKey - } - } - } - - // Get an instance of the NostrController - const nostrController = NostrController.getInstance() - - // Decrypt the encrypted content - const decrypted = await nostrController - .nip04Decrypt(usersPubkey, encryptedContent) - .catch((err) => { - // Log error and show a toast notification if decryption fails - console.log('An error occurred while decrypting app data', err) - toast.error('An error occurred while decrypting app data') - return null - }) - - // Return null if decryption fails - if (!decrypted) return null - - // Parse the decrypted content - const parsedContent = await parseJson<{ - blossomUrls: string[] - keyPair: Keys - }>(decrypted).catch((err) => { - // Log error and show a toast notification if parsing fails - console.log( - 'An error occurred in parsing the content of kind 30078 event', - err - ) - toast.error('An error occurred in parsing the content of kind 30078 event') - return null - }) - - // Return null if parsing fails - if (!parsedContent) return null - - const { blossomUrls, keyPair } = parsedContent - - // Return null if no blossom URLs are found - if (blossomUrls.length === 0) return null - - // Fetch additional user app data from the first blossom URL - const dataFromBlossom = await getUserAppDataFromBlossom( - blossomUrls[0], - keyPair.private - ) - - // Return null if fetching data from blossom fails - if (!dataFromBlossom) return null - - const { sigits, processedGiftWraps } = dataFromBlossom - - // Return the final user application data - return { - blossomUrls, - keyPair, - sigits, - processedGiftWraps - } -} - -export const updateUsersAppData = async (meta: Meta) => { - const appData = store.getState().userAppData - if (!appData || !appData.keyPair) return null - - const sigits = _.cloneDeep(appData.sigits) - - const createSignatureEvent = await parseJson( - meta.createSignature - ).catch((err) => { - console.log('err in parsing the createSignature event:>> ', err) - toast.error( - err.message || 'error occurred in parsing the create signature event' - ) - return null - }) - - if (!createSignatureEvent) return null - - const id = createSignatureEvent.id - let isUpdated = false - - // check if sigit already exists - if (id in sigits) { - // update meta only if incoming meta is more recent - // than already existing one - const existingMeta = sigits[id] - if (existingMeta.modifiedAt < meta.modifiedAt) { - sigits[id] = meta - isUpdated = true - } - } else { - sigits[id] = meta - isUpdated = true - } - - if (!isUpdated) return null - - const blossomUrls = [...appData.blossomUrls] - - const newBlossomUrl = await uploadUserAppDataToBlossom( - sigits, - appData.processedGiftWraps, - appData.keyPair.private - ).catch((err) => { - console.log( - 'An error occurred in uploading user app data file to blossom server', - err - ) - toast.error( - 'An error occurred in uploading user app data file to blossom server' - ) - return null - }) - - if (!newBlossomUrl) return null - - // insert new blossom url at the start of the array - blossomUrls.unshift(newBlossomUrl) - - // only keep last 10 blossom urls, delete older ones - if (blossomUrls.length > 10) { - const filesToDelete = blossomUrls.splice(10) - filesToDelete.forEach((url) => { - deleteBlossomFile(url, appData.keyPair!.private).catch((err) => { - console.log( - 'An error occurred in removing old file of user app data from blossom server', - err - ) - }) - }) - } - - const usersPubkey = store.getState().auth.usersPubkey! - - // encrypt content for storing in kind 30078 event - const nostrController = NostrController.getInstance() - const encryptedContent = await nostrController - .nip04Encrypt( - usersPubkey, - JSON.stringify({ - blossomUrls, - keyPair: appData.keyPair - }) - ) - .catch((err) => { - console.log( - 'An error occurred in encryption of content for app data', - err - ) - toast.error( - err.message || 'An error occurred in encryption of content for app data' - ) - return null - }) - - if (!encryptedContent) return null - - // generate the identifier for user's appData event - const dTag = await getDTagForUserAppData() - if (!dTag) return null - - const updatedEvent: UnsignedEvent = { - kind: kinds.Application, - pubkey: usersPubkey!, - created_at: unixNow(), - tags: [['d', dTag]], - content: encryptedContent - } - - const signedEvent = await nostrController - .signEvent(updatedEvent) - .catch((err) => { - console.log('An error occurred in signing event', err) - toast.error(err.message || 'An error occurred in signing event') - return null - }) - - if (!signedEvent) return null - - const relayMap = store.getState().relays.map || getDefaultRelayMap() - const writeRelays = Object.keys(relayMap).filter((key) => relayMap[key].write) - - const publishResult = await Promise.race([ - relayController.publish(signedEvent, writeRelays), - timeout(40 * 1000) - ]).catch((err) => { - console.log('err :>> ', err) - if (err.message === 'Timeout') { - toast.error('Timeout occurred in publishing updated app data') - } else if (Array.isArray(err)) { - err.forEach((errResult) => { - toast.error( - `Publishing to ${errResult.relay} caused the following error: ${errResult.error}` - ) - }) - } else { - toast.error( - 'An unexpected error occurred in publishing updated app data ' - ) - } - - return null - }) - - if (!publishResult) return null - - // update redux store - store.dispatch( - updateUserAppDataAction({ - sigits, - blossomUrls, - processedGiftWraps: [...appData.processedGiftWraps], - keyPair: { - ...appData.keyPair - } - }) - ) - - return signedEvent -} - -const deleteBlossomFile = async (url: string, privateKey: string) => { +export const deleteBlossomFile = async (url: string, privateKey: string) => { const pathname = new URL(url).pathname const hash = removeLeadingSlash(pathname) @@ -662,7 +350,7 @@ const deleteBlossomFile = async (url: string, privateKey: string) => { * @param privateKey - The private key used for encryption. * @returns A promise that resolves to the URL of the uploaded file. */ -const uploadUserAppDataToBlossom = async ( +export const uploadUserAppDataToBlossom = async ( sigits: { [key: string]: Meta }, processedGiftWraps: string[], privateKey: string @@ -730,7 +418,10 @@ const uploadUserAppDataToBlossom = async ( * @param privateKey - The private key used for decryption. * @returns A promise that resolves to the decrypted and parsed user application data. */ -const getUserAppDataFromBlossom = async (url: string, privateKey: string) => { +export const getUserAppDataFromBlossom = async ( + url: string, + privateKey: string +) => { // Initialize errorCode to track HTTP error codes let errorCode = 0 @@ -799,133 +490,6 @@ const getUserAppDataFromBlossom = async (url: string, privateKey: string) => { return parsedContent } -/** - * Function to subscribe to sigits notifications for a specified public key. - * @param pubkey - The public key to subscribe to. - * @returns A promise that resolves when the subscription is successful. - */ -export const subscribeForSigits = async ( - pubkey: string, - getNDKRelayList: (pubkey: Hexpubkey) => Promise -) => { - const ndkRelayList = await getNDKRelayList(pubkey) - - // Ensure relay list is not empty - if (ndkRelayList.readRelayUrls.length === 0) return - - // Define the filter for the subscription - const filter: Filter = { - kinds: [1059], - '#p': [pubkey] - } - - // Process the received event synchronously - const events = await relayController.fetchEvents(filter, [ - ...ndkRelayList.readRelayUrls - ]) - for (const e of events) { - await processReceivedEvent(e) - } - - // Async processing of the events has a race condition - // relayController.subscribeForEvents(filter, relaySet.read, (event) => { - // processReceivedEvent(event) - // }) -} - -const processReceivedEvent = async (event: Event, difficulty: number = 5) => { - const processedEvents = store.getState().userAppData?.processedGiftWraps - - // Abort processing if userAppData is undefined - if (!processedEvents) return - - if (processedEvents.includes(event.id)) return - - store.dispatch(updateProcessedGiftWraps([...processedEvents, event.id])) - - // validate PoW - // Count the number of leading zero bits in the hash - const leadingZeroes = countLeadingZeroes(event.id) - if (leadingZeroes < difficulty) return - - // decrypt the content of gift wrap event - const nostrController = NostrController.getInstance() - const decrypted = await nostrController.nip44Decrypt( - event.pubkey, - event.content - ) - - const internalUnsignedEvent = await parseJson(decrypted).catch( - (err) => { - console.log( - 'An error occurred in parsing the internal unsigned event', - err - ) - return null - } - ) - - if (!internalUnsignedEvent || internalUnsignedEvent.kind !== 938) return - - const meta = await parseJson(internalUnsignedEvent.content).catch( - (err) => { - console.log( - 'An error occurred in parsing the internal unsigned event', - err - ) - return null - } - ) - - if (!meta) return - - await updateUsersAppData(meta) -} - -/** - * Function to send a notification to a specified receiver. - * @param receiver - The recipient's public key. - * @param meta - Metadata associated with the notification. - */ -export const sendNotification = async ( - receiver: string, - meta: Meta, - getNDKRelayList: (pubkey: Hexpubkey) => Promise -) => { - // Retrieve the user's public key from the state - const usersPubkey = store.getState().auth.usersPubkey! - - // Create an unsigned event object with the provided metadata - const unsignedEvent: UnsignedEvent = { - kind: 938, - pubkey: usersPubkey, - content: JSON.stringify(meta), - tags: [], - created_at: unixNow() - } - - // Wrap the unsigned event with the receiver's information - const wrappedEvent = createWrap(unsignedEvent, receiver) - - const ndkRelayList = await getNDKRelayList(receiver) - - // Ensure relay list is not empty - if (ndkRelayList.readRelayUrls.length === 0) return - - // Publish the notification event to the recipient's read relays - await Promise.race([ - relayController.publish(wrappedEvent, [...ndkRelayList.readRelayUrls]), - timeout(40 * 1000) - ]).catch((err) => { - // Log an error if publishing the notification event fails - console.log( - `An error occurred while publishing notification event for ${hexToNpub(receiver)}`, - err - ) - throw err - }) -} - /** * Show user's name, first available in order: display_name, name, or npub as fallback * @param npub User identifier, it can be either pubkey or npub1 (we only show npub) -- 2.34.1 From 4f9fdd19b03a0c55699f382b45377585b3958af8 Mon Sep 17 00:00:00 2001 From: daniyal Date: Fri, 27 Dec 2024 17:21:02 +0500 Subject: [PATCH 09/16] chore: remove import of deleted file --- src/utils/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/utils/index.ts b/src/utils/index.ts index 1b00e7f..274ceab 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,7 +1,6 @@ export * from './auth' export * from './const' export * from './crypto' -export * from './dvm' export * from './hash' export * from './localStorage' export * from './mark' -- 2.34.1 From 006ed7b548eecfb7362b41ab5d4db4dfcba649bc Mon Sep 17 00:00:00 2001 From: daniyal Date: Sat, 28 Dec 2024 00:43:00 +0500 Subject: [PATCH 10/16] chore: fix fetching of user profile --- src/contexts/NDKContext.tsx | 20 +++++++------------- src/hooks/useAuth.ts | 7 +++---- src/layouts/Main.tsx | 2 +- 3 files changed, 11 insertions(+), 18 deletions(-) diff --git a/src/contexts/NDKContext.tsx b/src/contexts/NDKContext.tsx index 61f856c..5db3ba5 100644 --- a/src/contexts/NDKContext.tsx +++ b/src/contexts/NDKContext.tsx @@ -41,8 +41,7 @@ export interface NDKContextType { ) => Promise findMetadata: ( pubkey: string, - opts?: NDKSubscriptionOptions, - storeProfileEvent?: boolean + opts?: NDKSubscriptionOptions ) => Promise getNDKRelayList: (pubkey: Hexpubkey) => Promise publish: (event: NDKEvent, explicitRelayUrls?: string[]) => Promise @@ -205,21 +204,17 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => { */ const findMetadata = async ( pubkey: string, - opts?: NDKSubscriptionOptions, - storeProfileEvent?: boolean + opts?: NDKSubscriptionOptions ): Promise => { const npub = hexToNpub(pubkey) const user = new NDKUser({ npub }) user.ndk = ndk - return await user.fetchProfile( - { - cacheUsage: NDKSubscriptionCacheUsage.PARALLEL, - ...(opts || {}) - }, - storeProfileEvent - ) + return await user.fetchProfile({ + cacheUsage: NDKSubscriptionCacheUsage.PARALLEL, + ...(opts || {}) + }) } const getNDKRelayList = async (pubkey: Hexpubkey) => { @@ -250,8 +245,7 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => { ndkRelaySet = NDKRelaySet.fromRelayUrls(explicitRelayUrls, ndk) } - return event - .publish(ndkRelaySet, 10000) + return await Promise.race([event.publish(ndkRelaySet), timeout(3000)]) .then((res) => { const relaysPublishedOn = Array.from(res) return relaysPublishedOn.map((relay) => relay.url) diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index ff38a06..a59e4ba 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -25,9 +25,8 @@ export const useAuth = () => { const { getRelayInfo } = useDvm() const { findMetadata, getNDKRelayList } = useNDKContext() - const { auth: authState, relays: relaysState } = useAppSelector( - (state) => state - ) + const authState = useAppSelector((state) => state.auth) + const relaysState = useAppSelector((state) => state.relays) const checkSession = useCallback(() => { const savedAuthToken = getAuthToken() @@ -66,7 +65,7 @@ export const useAuth = () => { const emptyMetadata = getEmptyMetadataEvent() try { - const profile = await findMetadata(pubkey, {}, true) + const profile = await findMetadata(pubkey) if (profile && profile.profileEvent) { const event: Event = JSON.parse(profile.profileEvent) diff --git a/src/layouts/Main.tsx b/src/layouts/Main.tsx index e4bb7a6..bb58718 100644 --- a/src/layouts/Main.tsx +++ b/src/layouts/Main.tsx @@ -164,7 +164,7 @@ export const MainLayout = () => { if (loggedIn) { if (!loginMethod || !usersPubkey) return logout() - findMetadata(usersPubkey, {}, true).then((profile) => { + findMetadata(usersPubkey).then((profile) => { if (profile && profile.profileEvent) { try { const event: Event = JSON.parse(profile.profileEvent) -- 2.34.1 From 01bb68d87b41b5120e61ba85e9db73b71f236179 Mon Sep 17 00:00:00 2001 From: daniyal Date: Sat, 28 Dec 2024 00:44:21 +0500 Subject: [PATCH 11/16] chore: fix issue in publishing sigit --- src/hooks/useNDK.ts | 47 +++++++++++++++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/src/hooks/useNDK.ts b/src/hooks/useNDK.ts index fbd8d23..42fa053 100644 --- a/src/hooks/useNDK.ts +++ b/src/hooks/useNDK.ts @@ -2,7 +2,7 @@ import { useCallback } from 'react' import { toast } from 'react-toastify' import { bytesToHex } from '@noble/hashes/utils' -import { NDKEvent, NDKFilter, NDKKind } from '@nostr-dev-kit/ndk' +import { NDKEvent, NDKFilter, NDKKind, NDKRelaySet } from '@nostr-dev-kit/ndk' import _ from 'lodash' import { Event, @@ -35,14 +35,20 @@ import { getUserAppDataFromBlossom, hexToNpub, parseJson, + SIGIT_RELAY, unixNow, uploadUserAppDataToBlossom } from '../utils' export const useNDK = () => { const dispatch = useAppDispatch() - const { ndk, fetchEvent, fetchEventsFromUserRelays, publish } = - useNDKContext() + const { + ndk, + fetchEvent, + fetchEventsFromUserRelays, + publish, + getNDKRelayList + } = useNDKContext() const usersPubkey = useAppSelector((state) => state.auth.usersPubkey) const appData = useAppSelector((state) => state.userAppData) const processedEvents = useAppSelector( @@ -448,16 +454,33 @@ export const useNDK = () => { // Publish the notification event to the recipient's read relays const ndkEvent = new NDKEvent(ndk, wrappedEvent) - await publish(ndkEvent).catch((err) => { - // Log an error if publishing the notification event fails - console.log( - `An error occurred while publishing notification event for ${hexToNpub(receiver)}`, - err - ) - throw err - }) + + const ndkRelayList = await getNDKRelayList(receiver) + + const readRelayUrls = [...ndkRelayList.readRelayUrls] + if (!readRelayUrls.includes(SIGIT_RELAY)) { + readRelayUrls.push(SIGIT_RELAY) + } + + await ndkEvent + .publish(NDKRelaySet.fromRelayUrls(readRelayUrls, ndk, true)) + .then((publishedOnRelays) => { + if (publishedOnRelays.size === 0) { + throw new Error('Could not publish to any relay') + } + + return publishedOnRelays + }) + .catch((err) => { + // Log an error if publishing the notification event fails + console.log( + `An error occurred while publishing notification event for ${hexToNpub(receiver)}`, + err + ) + throw err + }) }, - [ndk, publish, usersPubkey] + [ndk, usersPubkey, getNDKRelayList] ) return { -- 2.34.1 From 95f5398736dfde35a82998d45d47e9a69a9b2094 Mon Sep 17 00:00:00 2001 From: daniyal Date: Sat, 28 Dec 2024 14:57:19 +0500 Subject: [PATCH 12/16] chore: replace the usage of ProfileMetadata with NDKUserProfile --- src/components/AppBar/AppBar.tsx | 31 ++++++----------- src/components/DrawPDFFields/index.tsx | 2 +- .../DrawPDFFields/internal/Counterpart.tsx | 15 ++++---- src/hooks/useAuth.ts | 18 +++------- src/layouts/Main.tsx | 20 +++-------- src/main.tsx | 3 +- src/pages/create/index.tsx | 2 +- src/pages/profile/index.tsx | 16 ++++----- src/pages/settings/profile/index.tsx | 28 ++++++--------- src/store/actionTypes.ts | 2 +- src/store/actions.ts | 2 +- src/store/metadata/action.ts | 8 ----- src/store/metadata/reducer.ts | 25 -------------- src/store/metadata/types.ts | 10 ------ src/store/rootReducer.ts | 16 +++------ src/store/user/action.ts | 17 ++++++++++ src/store/user/reducer.ts | 34 +++++++++++++++++++ src/store/user/types.ts | 23 +++++++++++++ src/store/userRobotImage/action.ts | 9 ----- src/store/userRobotImage/reducer.ts | 22 ------------ src/store/userRobotImage/types.ts | 9 ----- src/types/index.ts | 1 - src/types/profile.ts | 12 ------- src/utils/nostr.ts | 8 ++--- 24 files changed, 131 insertions(+), 202 deletions(-) delete mode 100644 src/store/metadata/action.ts delete mode 100644 src/store/metadata/reducer.ts delete mode 100644 src/store/metadata/types.ts create mode 100644 src/store/user/action.ts create mode 100644 src/store/user/reducer.ts create mode 100644 src/store/user/types.ts delete mode 100644 src/store/userRobotImage/action.ts delete mode 100644 src/store/userRobotImage/reducer.ts delete mode 100644 src/store/userRobotImage/types.ts delete mode 100644 src/types/profile.ts diff --git a/src/components/AppBar/AppBar.tsx b/src/components/AppBar/AppBar.tsx index e7f5d95..68b04dd 100644 --- a/src/components/AppBar/AppBar.tsx +++ b/src/components/AppBar/AppBar.tsx @@ -37,30 +37,19 @@ export const AppBar = () => { const [anchorElUser, setAnchorElUser] = useState(null) const authState = useAppSelector((state) => state.auth) - const metadataState = useAppSelector((state) => state.metadata) - const userRobotImage = useAppSelector((state) => state.userRobotImage) + const userProfile = useAppSelector((state) => state.user.profile) + const userRobotImage = useAppSelector((state) => state.user.robotImage) useEffect(() => { - if (metadataState) { - if (metadataState.content) { - const profileMetadata = JSON.parse(metadataState.content) - const { picture } = profileMetadata - - if (picture || userRobotImage) { - setUserAvatar(picture || userRobotImage) - } - - const npub = authState.usersPubkey - ? hexToNpub(authState.usersPubkey) - : '' - - setUsername(getProfileUsername(npub, profileMetadata)) - } else { - setUserAvatar(userRobotImage || '') - setUsername('') - } + const npub = authState.usersPubkey ? hexToNpub(authState.usersPubkey) : '' + if (userProfile) { + setUserAvatar(userProfile.image || userRobotImage || '') + setUsername(getProfileUsername(npub, userProfile)) + } else { + setUserAvatar('') + setUsername(getProfileUsername(npub)) } - }, [metadataState, userRobotImage, authState.usersPubkey]) + }, [userRobotImage, authState.usersPubkey, userProfile]) const handleOpenUserMenu = (event: React.MouseEvent) => { setAnchorElUser(event.currentTarget) diff --git a/src/components/DrawPDFFields/index.tsx b/src/components/DrawPDFFields/index.tsx index 156922f..71fef1c 100644 --- a/src/components/DrawPDFFields/index.tsx +++ b/src/components/DrawPDFFields/index.tsx @@ -679,7 +679,7 @@ export const DrawPDFFields = ({ renderValue={(value) => ( )} diff --git a/src/components/DrawPDFFields/internal/Counterpart.tsx b/src/components/DrawPDFFields/internal/Counterpart.tsx index 1e2f61b..b66cb8b 100644 --- a/src/components/DrawPDFFields/internal/Counterpart.tsx +++ b/src/components/DrawPDFFields/internal/Counterpart.tsx @@ -1,31 +1,32 @@ import React from 'react' -import { ProfileMetadata, User } from '../../../types' +import { User } from '../../../types' import _ from 'lodash' import { npubToHex, getProfileUsername } from '../../../utils' import { AvatarIconButton } from '../../UserAvatarIconButton' import styles from './Counterpart.module.scss' +import { NDKUserProfile } from '@nostr-dev-kit/ndk' interface CounterpartProps { npub: string - metadata: { - [key: string]: ProfileMetadata + userProfiles: { + [key: string]: NDKUserProfile } signers: User[] } export const Counterpart = React.memo( - ({ npub, metadata, signers }: CounterpartProps) => { + ({ npub, userProfiles, signers }: CounterpartProps) => { let displayValue = _.truncate(npub, { length: 16 }) const signer = signers.find((u) => u.pubkey === npubToHex(npub)) if (signer) { - const signerMetadata = metadata[signer.pubkey] - displayValue = getProfileUsername(npub, signerMetadata) + const profile = userProfiles[signer.pubkey] + displayValue = getProfileUsername(npub, profile) return (
{ */ const authAndGetMetadataAndRelaysMap = useCallback( async (pubkey: string) => { - const emptyMetadata = getEmptyMetadataEvent() - try { const profile = await findMetadata(pubkey) - - if (profile && profile.profileEvent) { - const event: Event = JSON.parse(profile.profileEvent) - dispatch(setMetadataEvent(event)) - } else { - dispatch(setMetadataEvent(emptyMetadata)) - } + dispatch(setUserProfile(profile)) } catch (err) { console.warn('Error occurred while finding metadata', err) - dispatch(setMetadataEvent(emptyMetadata)) } const timestamp = unixNow() diff --git a/src/layouts/Main.tsx b/src/layouts/Main.tsx index bb58718..40758aa 100644 --- a/src/layouts/Main.tsx +++ b/src/layouts/Main.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { Outlet, useNavigate, useSearchParams } from 'react-router-dom' -import { Event, getPublicKey, nip19 } from 'nostr-tools' +import { getPublicKey, nip19 } from 'nostr-tools' import { init as initNostrLogin } from 'nostr-login' import { NostrLoginAuthOptions } from 'nostr-login/dist/types' @@ -22,14 +22,14 @@ import { import { restoreState, - setMetadataEvent, + setUserProfile, updateKeyPair, updateLoginMethod, updateNostrLoginAuthMethod, - updateUserAppData + updateUserAppData, + setUserRobotImage } from '../store/actions' import { LoginMethod } from '../store/auth/types' -import { setUserRobotImage } from '../store/userRobotImage/action' import { getRoboHashPicture, loadState } from '../utils' @@ -165,17 +165,7 @@ export const MainLayout = () => { if (!loginMethod || !usersPubkey) return logout() findMetadata(usersPubkey).then((profile) => { - if (profile && profile.profileEvent) { - try { - const event: Event = JSON.parse(profile.profileEvent) - dispatch(setMetadataEvent(event)) - } catch (error) { - console.error( - 'An error occurred in parsing profile event from profile obj', - error - ) - } - } + dispatch(setUserProfile(profile)) }) } else { setIsLoading(false) diff --git a/src/main.tsx b/src/main.tsx index 05ea4ed..6b6b748 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -17,8 +17,7 @@ store.subscribe( _.throttle(() => { saveState({ auth: store.getState().auth, - metadata: store.getState().metadata, - userRobotImage: store.getState().userRobotImage, + user: store.getState().user, relays: store.getState().relays }) }, 1000) diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index f870849..8ca946a 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -1127,7 +1127,7 @@ export const CreatePage = () => { key={option.pubkey} > { const [pubkey, setPubkey] = useState() const [userProfile, setUserProfile] = useState(null) - const userRobotImage = useAppSelector((state) => state.userRobotImage) - const metadataState = useAppSelector((state) => state.metadata) + const userRobotImage = useAppSelector((state) => state.user.robotImage) + const currentUserProfile = useAppSelector((state) => state.user.profile) const { usersPubkey } = useAppSelector((state) => state.auth) const [isUsersOwnProfile, setIsUsersOwnProfile] = useState(false) @@ -58,12 +58,8 @@ export const ProfilePage = () => { }, [npub, usersPubkey]) useEffect(() => { - if (isUsersOwnProfile && metadataState) { - const ndkEvent = new NDKEvent(ndk, metadataState) - const profile = profileFromEvent(ndkEvent) - - setUserProfile(profile) - + if (isUsersOwnProfile && currentUserProfile) { + setUserProfile(currentUserProfile) setIsLoading(false) return @@ -81,7 +77,7 @@ export const ProfilePage = () => { setIsLoading(false) }) } - }, [ndk, isUsersOwnProfile, metadataState, pubkey, findMetadata]) + }, [ndk, isUsersOwnProfile, currentUserProfile, pubkey, findMetadata]) /** * Rendering text with button which copies the provided text diff --git a/src/pages/settings/profile/index.tsx b/src/pages/settings/profile/index.tsx index 8432406..57383a7 100644 --- a/src/pages/settings/profile/index.tsx +++ b/src/pages/settings/profile/index.tsx @@ -17,12 +17,7 @@ import { Tooltip } from '@mui/material' -import { - NDKEvent, - NDKUserProfile, - profileFromEvent, - serializeProfile -} from '@nostr-dev-kit/ndk' +import { NDKEvent, NDKUserProfile, serializeProfile } from '@nostr-dev-kit/ndk' import { launch as launchNostrLoginDialog } from 'nostr-login' import { kinds, nip19, UnsignedEvent } from 'nostr-tools' @@ -37,7 +32,7 @@ import { Container } from '../../../components/Container' import { Footer } from '../../../components/Footer/Footer' import { LoadingSpinner } from '../../../components/LoadingSpinner' -import { setMetadataEvent } from '../../../store/actions' +import { setUserProfile as updateUserProfile } from '../../../store/actions' import { LoginMethod, NostrLoginAuthMethod } from '../../../store/auth/types' import { Dispatch } from '../../../store/store' @@ -52,8 +47,8 @@ export const ProfileSettingsPage = () => { const [pubkey, setPubkey] = useState() const [userProfile, setUserProfile] = useState(null) - const userRobotImage = useAppSelector((state) => state.userRobotImage) - const metadataState = useAppSelector((state) => state.metadata) + const userRobotImage = useAppSelector((state) => state.user.robotImage) + const currentUserProfile = useAppSelector((state) => state.user.profile) const keys = useAppSelector((state) => state.auth?.keyPair) const { usersPubkey, loginMethod, nostrLoginAuthMethod } = useAppSelector( (state) => state.auth @@ -80,11 +75,8 @@ export const ProfileSettingsPage = () => { }, [npub, usersPubkey]) useEffect(() => { - if (isUsersOwnProfile && metadataState) { - const ndkEvent = new NDKEvent(ndk, metadataState) - const profile = profileFromEvent(ndkEvent) - - setUserProfile(profile) + if (isUsersOwnProfile && currentUserProfile) { + setUserProfile(currentUserProfile) setIsLoading(false) @@ -103,7 +95,7 @@ export const ProfileSettingsPage = () => { setIsLoading(false) }) } - }, [ndk, isUsersOwnProfile, metadataState, pubkey, findMetadata]) + }, [ndk, isUsersOwnProfile, currentUserProfile, pubkey, findMetadata]) const editItem = ( key: keyof NDKUserProfile, @@ -208,7 +200,7 @@ export const ProfileSettingsPage = () => { )}` ) - dispatch(setMetadataEvent(signedEvent)) + dispatch(updateUserProfile(userProfile)) } setSavingProfileMetadata(false) @@ -323,12 +315,12 @@ export const ProfileSettingsPage = () => { /> - {editItem('picture', 'Picture URL', undefined, undefined, { + {editItem('image', 'Picture URL', undefined, undefined, { endAdornment: isUsersOwnProfile ? robohashButton() : undefined })} {editItem('name', 'Username')} - {editItem('display_name', 'Display Name')} + {editItem('displayName', 'Display Name')} {editItem('nip05', 'Nostr Address (nip05)')} {editItem('lud16', 'Lightning Address (lud16)')} {editItem('about', 'About', true, 4)} diff --git a/src/store/actionTypes.ts b/src/store/actionTypes.ts index 90fa99b..9a76f3f 100644 --- a/src/store/actionTypes.ts +++ b/src/store/actionTypes.ts @@ -7,7 +7,7 @@ export const UPDATE_LOGIN_METHOD = 'UPDATE_LOGIN_METHOD' export const UPDATE_NOSTR_LOGIN_AUTH_METHOD = 'UPDATE_NOSTR_LOGIN_AUTH_METHOD' export const UPDATE_KEYPAIR = 'UPDATE_KEYPAIR' -export const SET_METADATA_EVENT = 'SET_METADATA_EVENT' +export const SET_USER_PROFILE = 'SET_USER_PROFILE' export const SET_USER_ROBOT_IMAGE = 'SET_USER_ROBOT_IMAGE' diff --git a/src/store/actions.ts b/src/store/actions.ts index bca5438..fd1fb47 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -2,7 +2,7 @@ import * as ActionTypes from './actionTypes' import { State } from './rootReducer' export * from './auth/action' -export * from './metadata/action' +export * from './user/action' export * from './relays/action' export * from './userAppData/action' diff --git a/src/store/metadata/action.ts b/src/store/metadata/action.ts deleted file mode 100644 index a36561a..0000000 --- a/src/store/metadata/action.ts +++ /dev/null @@ -1,8 +0,0 @@ -import * as ActionTypes from '../actionTypes' -import { SetMetadataEvent } from './types' -import { Event } from 'nostr-tools' - -export const setMetadataEvent = (payload: Event): SetMetadataEvent => ({ - type: ActionTypes.SET_METADATA_EVENT, - payload -}) diff --git a/src/store/metadata/reducer.ts b/src/store/metadata/reducer.ts deleted file mode 100644 index edb0571..0000000 --- a/src/store/metadata/reducer.ts +++ /dev/null @@ -1,25 +0,0 @@ -import * as ActionTypes from '../actionTypes' -import { MetadataDispatchTypes } from './types' -import { Event } from 'nostr-tools' - -const initialState: Event | null = null - -const reducer = ( - state = initialState, - action: MetadataDispatchTypes -): Event | null => { - switch (action.type) { - case ActionTypes.SET_METADATA_EVENT: - return { - ...action.payload - } - - case ActionTypes.RESTORE_STATE: - return action.payload.metadata || initialState - - default: - return state - } -} - -export default reducer diff --git a/src/store/metadata/types.ts b/src/store/metadata/types.ts deleted file mode 100644 index cbc38ef..0000000 --- a/src/store/metadata/types.ts +++ /dev/null @@ -1,10 +0,0 @@ -import * as ActionTypes from '../actionTypes' -import { Event } from 'nostr-tools' -import { RestoreState } from '../actions' - -export interface SetMetadataEvent { - type: typeof ActionTypes.SET_METADATA_EVENT - payload: Event -} - -export type MetadataDispatchTypes = SetMetadataEvent | RestoreState diff --git a/src/store/rootReducer.ts b/src/store/rootReducer.ts index 61b2837..4f9ac70 100644 --- a/src/store/rootReducer.ts +++ b/src/store/rootReducer.ts @@ -1,37 +1,31 @@ -import { Event } from 'nostr-tools' import { combineReducers } from 'redux' import { UserAppData } from '../types' import * as ActionTypes from './actionTypes' import authReducer from './auth/reducer' import { AuthDispatchTypes, AuthState } from './auth/types' -import metadataReducer from './metadata/reducer' +import userReducer from './user/reducer' import relaysReducer from './relays/reducer' import { RelaysDispatchTypes, RelaysState } from './relays/types' import UserAppDataReducer from './userAppData/reducer' -import userRobotImageReducer from './userRobotImage/reducer' -import { MetadataDispatchTypes } from './metadata/types' import { UserAppDataDispatchTypes } from './userAppData/types' -import { UserRobotImageDispatchTypes } from './userRobotImage/types' +import { UserDispatchTypes, UserState } from './user/types' export interface State { auth: AuthState - metadata?: Event - userRobotImage?: string + user: UserState relays: RelaysState userAppData?: UserAppData } type AppActions = | AuthDispatchTypes - | MetadataDispatchTypes - | UserRobotImageDispatchTypes + | UserDispatchTypes | RelaysDispatchTypes | UserAppDataDispatchTypes export const appReducer = combineReducers({ auth: authReducer, - metadata: metadataReducer, - userRobotImage: userRobotImageReducer, + user: userReducer, relays: relaysReducer, userAppData: UserAppDataReducer }) diff --git a/src/store/user/action.ts b/src/store/user/action.ts new file mode 100644 index 0000000..5a1ead7 --- /dev/null +++ b/src/store/user/action.ts @@ -0,0 +1,17 @@ +import { NDKUserProfile } from '@nostr-dev-kit/ndk' +import * as ActionTypes from '../actionTypes' +import { SetUserProfile, SetUserRobotImage } from './types' + +export const setUserRobotImage = ( + payload: string | null +): SetUserRobotImage => ({ + type: ActionTypes.SET_USER_ROBOT_IMAGE, + payload +}) + +export const setUserProfile = ( + payload: NDKUserProfile | null +): SetUserProfile => ({ + type: ActionTypes.SET_USER_PROFILE, + payload +}) diff --git a/src/store/user/reducer.ts b/src/store/user/reducer.ts new file mode 100644 index 0000000..f160462 --- /dev/null +++ b/src/store/user/reducer.ts @@ -0,0 +1,34 @@ +import * as ActionTypes from '../actionTypes' +import { UserDispatchTypes, UserState } from './types' + +const initialState: UserState = { + robotImage: null, + profile: null +} + +const reducer = ( + state = initialState, + action: UserDispatchTypes +): UserState => { + switch (action.type) { + case ActionTypes.SET_USER_ROBOT_IMAGE: + return { + ...state, + robotImage: action.payload + } + + case ActionTypes.SET_USER_PROFILE: + return { + ...state, + profile: action.payload + } + + // case ActionTypes.RESTORE_STATE: + // return action.payload. + + default: + return state + } +} + +export default reducer diff --git a/src/store/user/types.ts b/src/store/user/types.ts new file mode 100644 index 0000000..7f615f5 --- /dev/null +++ b/src/store/user/types.ts @@ -0,0 +1,23 @@ +import { NDKUserProfile } from '@nostr-dev-kit/ndk' +import * as ActionTypes from '../actionTypes' +import { RestoreState } from '../actions' + +export interface UserState { + robotImage: string | null + profile: NDKUserProfile | null +} + +export interface SetUserRobotImage { + type: typeof ActionTypes.SET_USER_ROBOT_IMAGE + payload: string | null +} + +export interface SetUserProfile { + type: typeof ActionTypes.SET_USER_PROFILE + payload: NDKUserProfile | null +} + +export type UserDispatchTypes = + | SetUserRobotImage + | SetUserProfile + | RestoreState diff --git a/src/store/userRobotImage/action.ts b/src/store/userRobotImage/action.ts deleted file mode 100644 index 5bec4ef..0000000 --- a/src/store/userRobotImage/action.ts +++ /dev/null @@ -1,9 +0,0 @@ -import * as ActionTypes from '../actionTypes' -import { SetUserRobotImage } from './types' - -export const setUserRobotImage = ( - payload: string | null -): SetUserRobotImage => ({ - type: ActionTypes.SET_USER_ROBOT_IMAGE, - payload -}) diff --git a/src/store/userRobotImage/reducer.ts b/src/store/userRobotImage/reducer.ts deleted file mode 100644 index db6bdbe..0000000 --- a/src/store/userRobotImage/reducer.ts +++ /dev/null @@ -1,22 +0,0 @@ -import * as ActionTypes from '../actionTypes' -import { UserRobotImageDispatchTypes } from './types' - -const initialState: string | null = null - -const reducer = ( - state = initialState, - action: UserRobotImageDispatchTypes -): string | null | undefined => { - switch (action.type) { - case ActionTypes.SET_USER_ROBOT_IMAGE: - return action.payload - - case ActionTypes.RESTORE_STATE: - return action.payload.userRobotImage || initialState - - default: - return state - } -} - -export default reducer diff --git a/src/store/userRobotImage/types.ts b/src/store/userRobotImage/types.ts deleted file mode 100644 index 2bef640..0000000 --- a/src/store/userRobotImage/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -import * as ActionTypes from '../actionTypes' -import { RestoreState } from '../actions' - -export interface SetUserRobotImage { - type: typeof ActionTypes.SET_USER_ROBOT_IMAGE - payload: string | null -} - -export type UserRobotImageDispatchTypes = SetUserRobotImage | RestoreState diff --git a/src/types/index.ts b/src/types/index.ts index fd242b2..5c5b715 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,7 +1,6 @@ export * from './cache' export * from './core' export * from './nostr' -export * from './profile' export * from './relay' export * from './zip' export * from './event' diff --git a/src/types/profile.ts b/src/types/profile.ts deleted file mode 100644 index 1dcfa6f..0000000 --- a/src/types/profile.ts +++ /dev/null @@ -1,12 +0,0 @@ -export interface ProfileMetadata { - name?: string - display_name?: string - /** @deprecated use name instead */ - username?: string - picture?: string - banner?: string - about?: string - website?: string - nip05?: string - lud16?: string -} diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index ad33ab8..600bd08 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -1,5 +1,5 @@ import { hexToBytes } from '@noble/hashes/utils' -import { NDKEvent } from '@nostr-dev-kit/ndk' +import { NDKEvent, NDKUserProfile } from '@nostr-dev-kit/ndk' import axios from 'axios' import { truncate } from 'lodash' import { @@ -18,7 +18,7 @@ import { import { toast } from 'react-toastify' import { NIP05_REGEX } from '../constants' import store from '../store/store' -import { Meta, ProfileMetadata, SignedEvent } from '../types' +import { Meta, SignedEvent } from '../types' import { SIGIT_BLOSSOM } from './const.ts' import { getHash } from './hash' import { parseJson, removeLeadingSlash } from './string' @@ -497,9 +497,9 @@ export const getUserAppDataFromBlossom = async ( */ export const getProfileUsername = ( npub: `npub1${string}` | string, - profile?: ProfileMetadata // todo: use NDKUserProfile + profile?: NDKUserProfile ) => - truncate(profile?.display_name || profile?.name || hexToNpub(npub), { + truncate(profile?.displayName || profile?.name || hexToNpub(npub), { length: 16 }) -- 2.34.1 From c4c0ecba4aa017da69374e45299affd9dd2ed58f Mon Sep 17 00:00:00 2001 From: daniyal Date: Sat, 4 Jan 2025 11:21:49 +0500 Subject: [PATCH 13/16] chore: handle restore state action in user reducer --- src/store/user/reducer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/store/user/reducer.ts b/src/store/user/reducer.ts index f160462..b48baca 100644 --- a/src/store/user/reducer.ts +++ b/src/store/user/reducer.ts @@ -23,8 +23,8 @@ const reducer = ( profile: action.payload } - // case ActionTypes.RESTORE_STATE: - // return action.payload. + case ActionTypes.RESTORE_STATE: + return action.payload.user || initialState default: return state -- 2.34.1 From 3a09d4c5956215a91e538dc92473781103527294 Mon Sep 17 00:00:00 2001 From: daniyal Date: Sat, 4 Jan 2025 11:24:35 +0500 Subject: [PATCH 14/16] chore: process received events all together instead of one by one which casuses in-consistencies due to async nature of redux updates --- src/contexts/NDKContext.tsx | 50 +++++-- src/hooks/useNDK.ts | 277 +++++++++++++++++++----------------- src/pages/create/index.tsx | 2 +- src/pages/home/index.tsx | 4 +- src/pages/sign/index.tsx | 2 +- src/pages/verify/index.tsx | 2 +- 6 files changed, 184 insertions(+), 153 deletions(-) diff --git a/src/contexts/NDKContext.tsx b/src/contexts/NDKContext.tsx index 5db3ba5..55100e8 100644 --- a/src/contexts/NDKContext.tsx +++ b/src/contexts/NDKContext.tsx @@ -27,17 +27,25 @@ import { export interface NDKContextType { ndk: NDK - fetchEvents: (filter: NDKFilter) => Promise - fetchEvent: (filter: NDKFilter) => Promise + fetchEvents: ( + filter: NDKFilter, + opts?: NDKSubscriptionOptions + ) => Promise + fetchEvent: ( + filter: NDKFilter, + opts?: NDKSubscriptionOptions + ) => Promise fetchEventsFromUserRelays: ( filter: NDKFilter | NDKFilter[], hexKey: string, - userRelaysType: UserRelaysType + userRelaysType: UserRelaysType, + opts?: NDKSubscriptionOptions ) => Promise fetchEventFromUserRelays: ( filter: NDKFilter | NDKFilter[], hexKey: string, - userRelaysType: UserRelaysType + userRelaysType: UserRelaysType, + opts?: NDKSubscriptionOptions ) => Promise findMetadata: ( pubkey: string, @@ -67,8 +75,8 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => { }, []) const ndk = useMemo(() => { - localStorage.setItem('debug', '*') - const dexieAdapter = new NDKCacheAdapterDexie({ dbName: 'degmod-db' }) + // localStorage.setItem('debug', '*') + const dexieAdapter = new NDKCacheAdapterDexie({ dbName: 'sigit-db' }) dexieAdapter.locking = true const ndk = new NDK({ enableOutboxModel: true, @@ -88,11 +96,15 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => { * @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 => { + const fetchEvents = async ( + filter: NDKFilter, + opts?: NDKSubscriptionOptions + ): Promise => { return ndk .fetchEvents(filter, { closeOnEose: true, - cacheUsage: NDKSubscriptionCacheUsage.PARALLEL + cacheUsage: NDKSubscriptionCacheUsage.PARALLEL, + ...opts }) .then((ndkEventSet) => { const ndkEvents = Array.from(ndkEventSet) @@ -112,8 +124,11 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => { * @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) + const fetchEvent = async ( + filter: NDKFilter, + opts?: NDKSubscriptionOptions + ) => { + const events = await fetchEvents(filter, opts) if (events.length === 0) return null return events[0] } @@ -130,7 +145,8 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => { const fetchEventsFromUserRelays = async ( filter: NDKFilter | NDKFilter[], hexKey: string, - userRelaysType: UserRelaysType + userRelaysType: UserRelaysType, + opts?: NDKSubscriptionOptions ): Promise => { // Find the user's relays (10s timeout). const relayUrls = await Promise.race([ @@ -156,7 +172,11 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => { return ndk .fetchEvents( filter, - { closeOnEose: true, cacheUsage: NDKSubscriptionCacheUsage.PARALLEL }, + { + closeOnEose: true, + cacheUsage: NDKSubscriptionCacheUsage.PARALLEL, + ...opts + }, relayUrls.length ? NDKRelaySet.fromRelayUrls(relayUrls, ndk, true) : undefined @@ -185,12 +205,14 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => { const fetchEventFromUserRelays = async ( filter: NDKFilter | NDKFilter[], hexKey: string, - userRelaysType: UserRelaysType + userRelaysType: UserRelaysType, + opts?: NDKSubscriptionOptions ) => { const events = await fetchEventsFromUserRelays( filter, hexKey, - userRelaysType + userRelaysType, + opts ) if (events.length === 0) return null return events[0] diff --git a/src/hooks/useNDK.ts b/src/hooks/useNDK.ts index 42fa053..ea1ab7c 100644 --- a/src/hooks/useNDK.ts +++ b/src/hooks/useNDK.ts @@ -2,7 +2,13 @@ import { useCallback } from 'react' import { toast } from 'react-toastify' import { bytesToHex } from '@noble/hashes/utils' -import { NDKEvent, NDKFilter, NDKKind, NDKRelaySet } from '@nostr-dev-kit/ndk' +import { + NDKEvent, + NDKFilter, + NDKKind, + NDKRelaySet, + NDKSubscriptionCacheUsage +} from '@nostr-dev-kit/ndk' import _ from 'lodash' import { Event, @@ -74,7 +80,9 @@ export const useNDK = () => { '#d': [dTag] } - const encryptedContent = await fetchEvent(filter) + const encryptedContent = await fetchEvent(filter, { + cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY + }) .then((event) => { if (event) return event.content @@ -171,38 +179,40 @@ export const useNDK = () => { }, [usersPubkey, fetchEvent]) const updateUsersAppData = useCallback( - async (meta: Meta) => { + async (metaArray: Meta[]) => { if (!appData || !appData.keyPair || !usersPubkey) return null const sigits = _.cloneDeep(appData.sigits) - - const createSignatureEvent = await parseJson( - meta.createSignature - ).catch((err) => { - console.log('err in parsing the createSignature event:>> ', err) - toast.error( - err.message || 'error occurred in parsing the create signature event' - ) - return null - }) - - if (!createSignatureEvent) return null - - const id = createSignatureEvent.id let isUpdated = false - // check if sigit already exists - if (id in sigits) { - // update meta only if incoming meta is more recent - // than already existing one - const existingMeta = sigits[id] - if (existingMeta.modifiedAt < meta.modifiedAt) { + for (const meta of metaArray) { + const createSignatureEvent = await parseJson( + meta.createSignature + ).catch((err) => { + console.log('Error in parsing the createSignature event:', err) + toast.error( + err.message || + 'Error occurred in parsing the create signature event' + ) + return null + }) + + if (!createSignatureEvent) continue + + const id = createSignatureEvent.id + + // Check if sigit already exists + if (id in sigits) { + // Update meta only if incoming meta is more recent + const existingMeta = sigits[id] + if (existingMeta.modifiedAt < meta.modifiedAt) { + sigits[id] = meta + isUpdated = true + } + } else { sigits[id] = meta isUpdated = true } - } else { - sigits[id] = meta - isUpdated = true } if (!isUpdated) return null @@ -215,34 +225,31 @@ export const useNDK = () => { appData.keyPair.private ).catch((err) => { console.log( - 'An error occurred in uploading user app data file to blossom server', + 'Error uploading user app data file to Blossom server:', err ) toast.error( - 'An error occurred in uploading user app data file to blossom server' + 'Error occurred in uploading user app data file to Blossom server' ) return null }) if (!newBlossomUrl) return null - // insert new blossom url at the start of the array + // Insert new blossom URL at the start of the array blossomUrls.unshift(newBlossomUrl) - // only keep last 10 blossom urls, delete older ones + // Keep only the last 10 Blossom URLs, delete older ones if (blossomUrls.length > 10) { const filesToDelete = blossomUrls.splice(10) filesToDelete.forEach((url) => { deleteBlossomFile(url, appData.keyPair!.private).catch((err) => { - console.log( - 'An error occurred in removing old file of user app data from blossom server', - err - ) + console.log('Error removing old file from Blossom server:', err) }) }) } - // encrypt content for storing in kind 30078 event + // Encrypt content for storing in kind 30078 event const nostrController = NostrController.getInstance() const encryptedContent = await nostrController .nip04Encrypt( @@ -253,20 +260,14 @@ export const useNDK = () => { }) ) .catch((err) => { - console.log( - 'An error occurred in encryption of content for app data', - err - ) - toast.error( - err.message || - 'An error occurred in encryption of content for app data' - ) + console.log('Error encrypting content for app data:', err) + toast.error(err.message || 'Error encrypting content for app data') return null }) if (!encryptedContent) return null - // generate the identifier for user's appData event + // Generate the identifier for user's appData event const dTag = await getDTagForUserAppData() if (!dTag) return null @@ -281,8 +282,8 @@ export const useNDK = () => { const signedEvent = await nostrController .signEvent(updatedEvent) .catch((err) => { - console.log('An error occurred in signing event', err) - toast.error(err.message || 'An error occurred in signing event') + console.log('Error signing event:', err) + toast.error(err.message || 'Error signing event') return null }) @@ -291,16 +292,14 @@ export const useNDK = () => { const ndkEvent = new NDKEvent(ndk, signedEvent) const publishResult = await publish(ndkEvent) - if (publishResult.length === 0) { - toast.error( - 'An unexpected error occurred in publishing updated app data ' - ) + if (publishResult.length === 0 || !publishResult) { + toast.error('Unexpected error occurred in publishing updated app data') return null } - if (!publishResult) return null + console.count('updateUserAppData useNDK') - // update redux store + // Update Redux store dispatch( updateUserAppDataAction({ sigits, @@ -317,94 +316,103 @@ export const useNDK = () => { [appData, dispatch, ndk, publish, usersPubkey] ) - const processReceivedEvent = useCallback( - async (event: NDKEvent, difficulty: number = 5) => { - // Abort processing if userAppData is undefined + const processReceivedEvents = useCallback( + async (events: NDKEvent[], difficulty: number = 5) => { if (!processedEvents) return - if (processedEvents.includes(event.id)) return + const validMetaArray: Meta[] = [] // Array to store valid Meta objects + const updatedProcessedEvents = [...processedEvents] // Keep track of processed event IDs - dispatch(updateProcessedGiftWraps([...processedEvents, event.id])) + for (const event of events) { + // Skip already processed events + if (processedEvents.includes(event.id)) continue - // validate PoW - // Count the number of leading zero bits in the hash - const leadingZeroes = countLeadingZeroes(event.id) - if (leadingZeroes < difficulty) return + // Validate PoW + const leadingZeroes = countLeadingZeroes(event.id) + if (leadingZeroes < difficulty) continue - // decrypt the content of gift wrap event - const nostrController = NostrController.getInstance() - const decrypted = await nostrController.nip44Decrypt( - event.pubkey, - event.content - ) + // Decrypt the content of the gift wrap event + const nostrController = NostrController.getInstance() + const decrypted = await nostrController + .nip44Decrypt(event.pubkey, event.content) + .catch((err) => { + console.log('An error occurred in decrypting event content', err) + return null + }) - const internalUnsignedEvent = await parseJson( - decrypted - ).catch((err) => { - console.log( - 'An error occurred in parsing the internal unsigned event', - err - ) - return null - }) + if (!decrypted) continue - if (!internalUnsignedEvent || internalUnsignedEvent.kind !== 938) return - - const parsedContent = await parseJson( - internalUnsignedEvent.content - ).catch((err) => { - console.log( - 'An error occurred in parsing the internal unsigned event', - err - ) - return null - }) - - if (!parsedContent) return - - let meta: Meta - - if (isSigitNotification(parsedContent)) { - const notification = parsedContent - if (!notification.keys || !usersPubkey) return - - let encryptionKey: string | undefined - - const { sender, keys } = notification.keys - - // Retrieve the user's public key from the state - const usersNpub = hexToNpub(usersPubkey) - - // Check if the user's public key is in the keys object - if (usersNpub in keys) { - // Instantiate the NostrController to decrypt the encryption key - const nostrController = NostrController.getInstance() - const decrypted = await nostrController - .nip04Decrypt(sender, keys[usersNpub]) - .catch((err) => { - console.log('An error occurred in decrypting encryption key', err) - return undefined - }) - - encryptionKey = decrypted - } - try { - meta = await fetchMetaFromFileStorage( - notification.metaUrl, - encryptionKey + const internalUnsignedEvent = await parseJson( + decrypted + ).catch((err) => { + console.log( + 'An error occurred in parsing the internal unsigned event', + err ) - } catch (error) { - console.error( - `An error occured fetching meta file from storage`, - error - ) - return + return null + }) + + if (!internalUnsignedEvent || internalUnsignedEvent.kind !== 938) + continue + + const parsedContent = await parseJson( + internalUnsignedEvent.content + ).catch((err) => { + console.log('An error occurred in parsing event content', err) + return null + }) + + if (!parsedContent) continue + + let meta: Meta + + if (isSigitNotification(parsedContent)) { + const notification = parsedContent + if (!notification.keys || !usersPubkey) continue + + let encryptionKey: string | undefined + const { sender, keys } = notification.keys + const usersNpub = hexToNpub(usersPubkey) + + if (usersNpub in keys) { + encryptionKey = await nostrController + .nip04Decrypt(sender, keys[usersNpub]) + .catch((err) => { + console.log( + 'An error occurred in decrypting encryption key', + err + ) + return undefined + }) + } + + try { + meta = await fetchMetaFromFileStorage( + notification.metaUrl, + encryptionKey + ) + } catch (error) { + console.error( + 'An error occurred fetching meta file from storage', + error + ) + continue + } + } else { + meta = parsedContent } - } else { - meta = parsedContent + + validMetaArray.push(meta) // Add valid Meta to the array + updatedProcessedEvents.push(event.id) // Mark event as processed } - await updateUsersAppData(meta) + // Update processed events in the Redux store + dispatch(updateProcessedGiftWraps(updatedProcessedEvents)) + + // Pass the array of Meta objects to updateUsersAppData + if (validMetaArray.length > 0) { + await updateUsersAppData(validMetaArray) + } }, [dispatch, processedEvents, updateUsersAppData, usersPubkey] ) @@ -421,14 +429,15 @@ export const useNDK = () => { const events = await fetchEventsFromUserRelays( filter, pubkey, - UserRelaysType.Read + UserRelaysType.Read, + { + cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY + } ) - for (const e of events) { - await processReceivedEvent(e) - } + await processReceivedEvents(events) }, - [fetchEventsFromUserRelays, processReceivedEvent] + [fetchEventsFromUserRelays, processReceivedEvents] ) /** diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index 8ca946a..de9c3d3 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -904,7 +904,7 @@ export const CreatePage = () => { setLoadingSpinnerDesc('Updating user app data') - const event = await updateUsersAppData(meta) + const event = await updateUsersAppData([meta]) if (!event) return const metaUrl = await uploadMetaToFileStorage(meta, encryptionKey) diff --git a/src/pages/home/index.tsx b/src/pages/home/index.tsx index f7166e2..d81dd1b 100644 --- a/src/pages/home/index.tsx +++ b/src/pages/home/index.tsx @@ -58,7 +58,7 @@ export const HomePage = () => { const usersAppData = useAppSelector((state) => state.userAppData) useEffect(() => { - if (usersAppData) { + if (usersAppData?.sigits) { const getSigitInfo = async () => { const parsedSigits: { [key: string]: SigitCardDisplayInfo } = {} for (const key in usersAppData.sigits) { @@ -80,7 +80,7 @@ export const HomePage = () => { setSigits(usersAppData.sigits) getSigitInfo() } - }, [usersAppData]) + }, [usersAppData?.sigits]) const onDrop = useCallback( async (acceptedFiles: File[]) => { diff --git a/src/pages/sign/index.tsx b/src/pages/sign/index.tsx index d4071e0..8eb782e 100644 --- a/src/pages/sign/index.tsx +++ b/src/pages/sign/index.tsx @@ -646,7 +646,7 @@ export const SignPage = () => { encryptionKey: string | undefined ) => { setLoadingSpinnerDesc('Updating users app data') - const updatedEvent = await updateUsersAppData(meta) + const updatedEvent = await updateUsersAppData([meta]) if (!updatedEvent) { setIsLoading(false) return diff --git a/src/pages/verify/index.tsx b/src/pages/verify/index.tsx index 43dcc4b..e39ca44 100644 --- a/src/pages/verify/index.tsx +++ b/src/pages/verify/index.tsx @@ -353,7 +353,7 @@ export const VerifyPage = () => { updatedMeta.timestamps = [...finalTimestamps] updatedMeta.modifiedAt = unixNow() - const updatedEvent = await updateUsersAppData(updatedMeta) + const updatedEvent = await updateUsersAppData([updatedMeta]) if (!updatedEvent) return const metaUrl = await uploadMetaToFileStorage( -- 2.34.1 From 0d93e16f3a0b3d9a28713eee6a39fd3636ab3527 Mon Sep 17 00:00:00 2001 From: daniyal Date: Sat, 4 Jan 2025 11:33:44 +0500 Subject: [PATCH 15/16] chore: enable debug logs only in dev mode --- src/contexts/NDKContext.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/contexts/NDKContext.tsx b/src/contexts/NDKContext.tsx index 55100e8..ba1d734 100644 --- a/src/contexts/NDKContext.tsx +++ b/src/contexts/NDKContext.tsx @@ -75,7 +75,9 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => { }, []) const ndk = useMemo(() => { - // localStorage.setItem('debug', '*') + if (import.meta.env.MODE === 'development') { + localStorage.setItem('debug', '*') + } const dexieAdapter = new NDKCacheAdapterDexie({ dbName: 'sigit-db' }) dexieAdapter.locking = true const ndk = new NDK({ -- 2.34.1 From 48f85f54c8ce11787452fc55796a0618d208d21e Mon Sep 17 00:00:00 2001 From: daniyal Date: Mon, 6 Jan 2025 16:04:54 +0500 Subject: [PATCH 16/16] chore: quick fixes --- src/components/DrawPDFFields/internal/Counterpart.tsx | 2 +- src/hooks/useNDK.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/components/DrawPDFFields/internal/Counterpart.tsx b/src/components/DrawPDFFields/internal/Counterpart.tsx index b66cb8b..508792d 100644 --- a/src/components/DrawPDFFields/internal/Counterpart.tsx +++ b/src/components/DrawPDFFields/internal/Counterpart.tsx @@ -26,7 +26,7 @@ export const Counterpart = React.memo( return (
{ const ndkRelayList = await getNDKRelayList(receiver) - const readRelayUrls = [...ndkRelayList.readRelayUrls] + const readRelayUrls: string[] = [] + + if (ndkRelayList?.readRelayUrls) { + readRelayUrls.push(...ndkRelayList.readRelayUrls) + } + if (!readRelayUrls.includes(SIGIT_RELAY)) { readRelayUrls.push(SIGIT_RELAY) } -- 2.34.1