diff --git a/package-lock.json b/package-lock.json index af41ed4..65fb09a 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", @@ -1712,65 +1714,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" }, @@ -1780,6 +1796,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", @@ -3859,6 +3908,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 60c9a2b..98bc510 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/App.tsx b/src/App.tsx index 11434af..3829ba6 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/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 2a43b3b..71fef1c 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, { useCallback, useEffect, useState } from 'react' -import { ProfileMetadata, User, UserRole, KeyboardCode } from '../../types' +import { User, UserRole, KeyboardCode } from '../../types' import { MouseState, DrawnField, DrawTool } from '../../types/drawing' import { hexToNpub, npubToHex, getProfileUsername } from '../../utils' import { SigitFile } from '../../utils/file' @@ -27,6 +27,7 @@ const MINIMUM_RECT_SIZE = { width: 10, height: 10 } as const +import { NDKUserProfile } from '@nostr-dev-kit/ndk' const DEFAULT_START_SIZE = { width: 140, @@ -42,7 +43,7 @@ type FieldIndexer = [...PageIndexer, field: number] interface DrawPdfFieldsProps { users: User[] - metadata: { [key: string]: ProfileMetadata } + userProfiles: { [key: string]: NDKUserProfile } sigitFiles: SigitFile[] updateSigitFiles: Updater selectedTool?: DrawTool @@ -50,7 +51,7 @@ interface DrawPdfFieldsProps { export const DrawPDFFields = ({ selectedTool, - metadata, + userProfiles, sigitFiles, updateSigitFiles, users @@ -678,17 +679,17 @@ export const DrawPDFFields = ({ renderValue={(value) => ( )} > {signers.map((signer, index) => { const npub = hexToNpub(signer.pubkey) - const profileMetadata = metadata[signer.pubkey] + const profile = userProfiles[signer.pubkey] const displayValue = getProfileUsername( npub, - profileMetadata + profile ) // make current signers dropdown visible if ( @@ -707,7 +708,7 @@ export const DrawPDFFields = ({ { + ({ 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 profile = useProfileMetadata(pubkey) const name = getProfileUsername(pubkey, profile) - const image = profile?.picture + const image = profile?.image return ( Promise + fetchEvent: ( + filter: NDKFilter, + opts?: NDKSubscriptionOptions + ) => Promise + fetchEventsFromUserRelays: ( + filter: NDKFilter | NDKFilter[], + hexKey: string, + userRelaysType: UserRelaysType, + opts?: NDKSubscriptionOptions + ) => Promise + fetchEventFromUserRelays: ( + filter: NDKFilter | NDKFilter[], + hexKey: string, + userRelaysType: UserRelaysType, + opts?: NDKSubscriptionOptions + ) => Promise + findMetadata: ( + pubkey: string, + opts?: NDKSubscriptionOptions + ) => Promise + getNDKRelayList: (pubkey: Hexpubkey) => 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(() => { + if (import.meta.env.MODE === 'development') { + localStorage.setItem('debug', '*') + } + const dexieAdapter = new NDKCacheAdapterDexie({ dbName: 'sigit-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, + opts?: NDKSubscriptionOptions + ): Promise => { + return ndk + .fetchEvents(filter, { + closeOnEose: true, + cacheUsage: NDKSubscriptionCacheUsage.PARALLEL, + ...opts + }) + .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, + opts?: NDKSubscriptionOptions + ) => { + const events = await fetchEvents(filter, opts) + 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, + opts?: NDKSubscriptionOptions + ): 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[] + }) + + if (!relayUrls.includes(SIGIT_RELAY)) { + relayUrls.push(SIGIT_RELAY) + } + + return ndk + .fetchEvents( + filter, + { + closeOnEose: true, + cacheUsage: NDKSubscriptionCacheUsage.PARALLEL, + ...opts + }, + 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, + opts?: NDKSubscriptionOptions + ) => { + const events = await fetchEventsFromUserRelays( + filter, + hexKey, + userRelaysType, + opts + ) + 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, + opts?: NDKSubscriptionOptions + ): Promise => { + const npub = hexToNpub(pubkey) + + const user = new NDKUser({ npub }) + user.ndk = ndk + + return await user.fetchProfile({ + cacheUsage: NDKSubscriptionCacheUsage.PARALLEL, + ...(opts || {}) + }) + } + + 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[] + ): Promise => { + if (!event.sig) throw new Error('Before publishing first sign the event!') + + let ndkRelaySet: NDKRelaySet | undefined + + if (explicitRelayUrls && explicitRelayUrls.length > 0) { + if (!explicitRelayUrls.includes(SIGIT_RELAY)) { + explicitRelayUrls = [...explicitRelayUrls, SIGIT_RELAY] + } + ndkRelaySet = NDKRelaySet.fromRelayUrls(explicitRelayUrls, ndk) + } + + return await Promise.race([event.publish(ndkRelaySet), timeout(3000)]) + .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/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/controllers/MetadataController.ts b/src/controllers/MetadataController.ts deleted file mode 100644 index 984afd3..0000000 --- a/src/controllers/MetadataController.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { - Event, - Filter, - VerifiedEvent, - kinds, - validateEvent, - verifyEvent -} from 'nostr-tools' -import { toast } from 'react-toastify' -import { EventEmitter } from 'tseep' -import { NostrController, relayController } from '.' -import { localCache } from '../services' -import { ProfileMetadata, RelaySet } from '../types' -import { - findRelayListAndUpdateCache, - findRelayListInCache, - getDefaultRelaySet, - getUserRelaySet, - isOlderThanOneDay, - unixNow -} 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 { - if (!MetadataController.instance) { - MetadataController.instance = new MetadataController() - } - 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 - * 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 - } - } - - /** - * 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/controllers/RelayController.ts b/src/controllers/RelayController.ts deleted file mode 100644 index df33b4b..0000000 --- a/src/controllers/RelayController.ts +++ /dev/null @@ -1,306 +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 - } - - /** - * 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[] = [] - ): 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 dc1f76f..e7302ce 100644 --- a/src/controllers/index.ts +++ b/src/controllers/index.ts @@ -1,4 +1 @@ -export * from './AuthController' -export * from './MetadataController' export * from './NostrController' -export * from './RelayController' diff --git a/src/hooks/index.ts b/src/hooks/index.ts index e7ec305..cbadfee 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,2 +1,7 @@ export * from './store' +export * from './useAuth' export * from './useDidMount' +export * from './useDvm' +export * from './useLogout' +export * from './useNDK' +export * from './useNDKContext' diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts new file mode 100644 index 0000000..e0e75fb --- /dev/null +++ b/src/hooks/useAuth.ts @@ -0,0 +1,127 @@ +import { EventTemplate } from 'nostr-tools' +import { useCallback } from 'react' +import { NostrController } from '../controllers' +import { appPrivateRoutes } from '../routes' +import { + setAuthState, + setRelayMapAction, + setUserProfile +} from '../store/actions' +import { + base64DecodeAuthToken, + compareObjects, + createAndSaveAuthToken, + getAuthToken, + getRelayMapFromNDKRelayList, + unixNow +} from '../utils' +import { useAppDispatch, useAppSelector } from './store' +import { useNDKContext } from './useNDKContext' +import { useDvm } from './useDvm' + +export const useAuth = () => { + const dispatch = useAppDispatch() + const { getRelayInfo } = useDvm() + const { findMetadata, getNDKRelayList } = useNDKContext() + + const authState = useAppSelector((state) => state.auth) + const relaysState = useAppSelector((state) => state.relays) + + 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) => { + try { + const profile = await findMetadata(pubkey) + dispatch(setUserProfile(profile)) + } catch (err) { + console.warn('Error occurred while finding metadata', err) + } + + 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 ndkRelayList = await getNDKRelayList(pubkey) + const relays = ndkRelayList.relays + + if (relays.length < 1) { + // Navigate user to relays page if relay map is empty + return appPrivateRoutes.relays + } + + getRelayInfo(relays) + + const relayMap = getRelayMapFromNDKRelayList(ndkRelayList) + + if (authState.loggedIn && !compareObjects(relaysState?.map, relayMap)) { + dispatch(setRelayMapAction(relayMap)) + } + + return appPrivateRoutes.homePage + }, + [ + dispatch, + findMetadata, + getNDKRelayList, + getRelayInfo, + authState, + relaysState + ] + ) + + return { + authAndGetMetadataAndRelaysMap, + checkSession + } +} 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/useNDK.ts b/src/hooks/useNDK.ts new file mode 100644 index 0000000..ad9e54f --- /dev/null +++ b/src/hooks/useNDK.ts @@ -0,0 +1,512 @@ +import { useCallback } from 'react' +import { toast } from 'react-toastify' + +import { bytesToHex } from '@noble/hashes/utils' +import { + NDKEvent, + NDKFilter, + NDKKind, + NDKRelaySet, + NDKSubscriptionCacheUsage +} 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 { + isSigitNotification, + Meta, + SigitNotification, + UserAppData, + UserRelaysType +} from '../types' +import { + countLeadingZeroes, + createWrap, + deleteBlossomFile, + fetchMetaFromFileStorage, + getDTagForUserAppData, + getUserAppDataFromBlossom, + hexToNpub, + parseJson, + SIGIT_RELAY, + unixNow, + uploadUserAppDataToBlossom +} from '../utils' + +export const useNDK = () => { + const dispatch = useAppDispatch() + const { + ndk, + fetchEvent, + fetchEventsFromUserRelays, + publish, + getNDKRelayList + } = 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 + + // Get an instance of the NostrController + const nostrController = NostrController.getInstance() + + // Decryption can fail down in the code if extension options changed + // Forcefully log out the user if we detect missmatch between pubkeys + if (usersPubkey !== (await nostrController.capturePublicKey())) { + 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, { + cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY + }) + .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 + } + } + } + + // 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 (metaArray: Meta[]) => { + if (!appData || !appData.keyPair || !usersPubkey) return null + + const sigits = _.cloneDeep(appData.sigits) + let isUpdated = false + + 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 + } + } + + if (!isUpdated) return null + + const blossomUrls = [...appData.blossomUrls] + + const newBlossomUrl = await uploadUserAppDataToBlossom( + sigits, + appData.processedGiftWraps, + appData.keyPair.private + ).catch((err) => { + console.log( + 'Error uploading user app data file to Blossom server:', + err + ) + toast.error( + '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) + + // 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('Error removing old file 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('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 + 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('Error signing event:', err) + toast.error(err.message || 'Error signing event') + return null + }) + + if (!signedEvent) return null + + const ndkEvent = new NDKEvent(ndk, signedEvent) + const publishResult = await publish(ndkEvent) + + if (publishResult.length === 0 || !publishResult) { + toast.error('Unexpected error occurred in publishing updated app data') + return null + } + + console.count('updateUserAppData useNDK') + + // Update Redux store + dispatch( + updateUserAppDataAction({ + sigits, + blossomUrls, + processedGiftWraps: [...appData.processedGiftWraps], + keyPair: { + ...appData.keyPair + } + }) + ) + + return signedEvent + }, + [appData, dispatch, ndk, publish, usersPubkey] + ) + + const processReceivedEvents = useCallback( + async (events: NDKEvent[], difficulty: number = 5) => { + if (!processedEvents) return + + const validMetaArray: Meta[] = [] // Array to store valid Meta objects + const updatedProcessedEvents = [...processedEvents] // Keep track of processed event IDs + + for (const event of events) { + // Skip already processed events + if (processedEvents.includes(event.id)) continue + + // Validate PoW + const leadingZeroes = countLeadingZeroes(event.id) + if (leadingZeroes < difficulty) continue + + // 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 + }) + + if (!decrypted) continue + + 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) + 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 + } + + validMetaArray.push(meta) // Add valid Meta to the array + updatedProcessedEvents.push(event.id) // Mark event as processed + } + + // 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] + ) + + 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, + { + cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY + } + ) + + await processReceivedEvents(events) + }, + [fetchEventsFromUserRelays, processReceivedEvents] + ) + + /** + * Function to send a notification to a specified receiver. + * @param receiver - The recipient's public key. + * @param notification - Url pointing to metadata associated with the notification on blossom and keys to decrypt. + */ + const sendNotification = useCallback( + async (receiver: string, notification: SigitNotification) => { + if (!usersPubkey) return + + // Create an unsigned event object with the provided metadata + const unsignedEvent: UnsignedEvent = { + kind: 938, + pubkey: usersPubkey, + content: JSON.stringify(notification), + 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) + + const ndkRelayList = await getNDKRelayList(receiver) + + const readRelayUrls: string[] = [] + + if (ndkRelayList?.readRelayUrls) { + readRelayUrls.push(...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, usersPubkey, getNDKRelayList] + ) + + return { + getUsersAppData, + subscribeForSigits, + updateUsersAppData, + sendNotification + } +} 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/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 5410dec..85daf75 100644 --- a/src/layouts/Main.tsx +++ b/src/layouts/Main.tsx @@ -1,40 +1,49 @@ -import { Event, getPublicKey, kinds, nip19 } from 'nostr-tools' import { useCallback, useEffect, useRef, useState } from 'react' import { Outlet, useNavigate, useSearchParams } from 'react-router-dom' + +import { getPublicKey, 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 { NostrController } from '../controllers' + import { - AuthController, - MetadataController, - NostrController -} from '../controllers' + useAppDispatch, + useAppSelector, + useAuth, + useLogout, + useNDK, + useNDKContext +} from '../hooks' + import { restoreState, - setMetadataEvent, + setUserProfile, updateKeyPair, updateLoginMethod, updateNostrLoginAuthMethod, - updateUserAppData + updateUserAppData, + setUserRobotImage } from '../store/actions' -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' + +import { getRoboHashPicture, loadState } from '../utils' + +import styles from './style.module.scss' export const MainLayout = () => { const [searchParams, setSearchParams] = useSearchParams() const navigate = useNavigate() const dispatch = useAppDispatch() const logout = useLogout() + const { findMetadata } = useNDKContext() + const { authAndGetMetadataAndRelaysMap } = useAuth() + const { getUsersAppData, subscribeForSigits } = useNDK() + const [isLoading, setIsLoading] = useState(true) const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState(`Loading App`) const isLoggedIn = useAppSelector((state) => state.auth?.loggedIn) @@ -61,11 +70,9 @@ export const MainLayout = () => { dispatch(updateLoginMethod(LoginMethod.nostrLogin)) const nostrController = NostrController.getInstance() - const authController = new AuthController() const pubkey = await nostrController.capturePublicKey() - const redirectPath = - await authController.authAndGetMetadataAndRelaysMap(pubkey) + const redirectPath = await authAndGetMetadataAndRelaysMap(pubkey) if (redirectPath) { navigateAfterLogin(redirectPath) @@ -105,13 +112,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}`) } @@ -151,8 +155,6 @@ export const MainLayout = () => { }, [dispatch]) useEffect(() => { - const metadataController = MetadataController.getInstance() - const restoredState = loadState() if (restoredState) { dispatch(restoreState(restoredState)) @@ -162,19 +164,8 @@ 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) - } - }) - - metadataController.findMetadata(usersPubkey).then((metadataEvent) => { - if (metadataEvent) handleMetadataEvent(metadataEvent) + findMetadata(usersPubkey).then((profile) => { + dispatch(setUserProfile(profile)) }) } else { setIsLoading(false) @@ -201,7 +192,7 @@ export const MainLayout = () => { hasSubscribed.current = true } } - }, [authState, isLoggedIn, usersAppData]) + }, [authState, isLoggedIn, usersAppData, subscribeForSigits]) /** * When authState change user logged in / or app reloaded diff --git a/src/main.tsx b/src/main.tsx index a8b1898..6b6b748 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -11,13 +11,13 @@ 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(() => { saveState({ auth: store.getState().auth, - metadata: store.getState().metadata, - userRobotImage: store.getState().userRobotImage, + user: store.getState().user, relays: store.getState().relays }) }, 1000) @@ -28,7 +28,9 @@ ReactDOM.createRoot(document.getElementById('root')!).render( - + + + diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index bd3893e..de9c3d3 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, kinds } 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,20 +19,16 @@ 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 } from '../../controllers' import { appPrivateRoutes, appPublicRoutes } from '../../routes' import { CreateSignatureEventContent, KeyboardCode, Meta, - ProfileMetadata, SigitNotification, SignedEvent, User, + UserRelaysType, UserRole } from '../../types' import { @@ -48,13 +43,10 @@ import { unixNow, npubToHex, queryNip05, - sendNotification, signEventForMetaFile, - updateUsersAppData, uploadToFileStorage, DEFAULT_TOOLBOX, settleAllFullfilfedPromises, - DEFAULT_LOOK_UP_RELAY_LIST, uploadMetaToFileStorage } from '../../utils' import { Container } from '../../components/Container' @@ -83,13 +75,19 @@ import { Autocomplete } from '@mui/material' import _, { truncate } from 'lodash' import * as React from 'react' import { AvatarIconButton } from '../../components/UserAvatarIconButton' +import { NDKUserProfile, NostrEvent } from '@nostr-dev-kit/ndk' +import { useNDKContext } from '../../hooks/useNDKContext.ts' +import { useNDK } from '../../hooks/useNDK.ts' import { useImmer } from 'use-immer' -type FoundUser = Event & { npub: string } +type FoundUser = NostrEvent & { npub: string } export const CreatePage = () => { const navigate = useNavigate() const location = useLocation() + const { findMetadata, fetchEventsFromUserRelays } = useNDKContext() + const { updateUsersAppData, sendNotification } = useNDK() + const { uploadedFiles } = location.state || {} const [currentFile, setCurrentFile] = useState() const isActive = (file: File) => file.name === currentFile?.name @@ -121,9 +119,10 @@ export const CreatePage = () => { const nostrController = NostrController.getInstance() - const [metadata, setMetadata] = useState<{ [key: string]: ProfileMetadata }>( - {} - ) + const [userProfiles, setUserProfiles] = useState<{ + [key: string]: NDKUserProfile + }>({}) + const [drawnFiles, updateDrawnFiles] = useImmer([]) const [parsingPdf, setIsParsing] = useState(false) @@ -170,32 +169,20 @@ export const CreatePage = () => { setSearchUsersLoading(true) - const relayController = RelayController.getInstance() - const metadataController = MetadataController.getInstance() - - const relaySet = await metadataController.findRelayListMetadata(usersPubkey) - - DEFAULT_LOOK_UP_RELAY_LIST.forEach((relay) => { - if (!relaySet.write.includes(relay)) relaySet.write.push(relay) - if (!relaySet.read.includes(relay)) relaySet.read.push(relay) - }) - - const uniqueReadRelaySet = [...new Set(relaySet.read)] - const searchTerm = searchString.trim() - relayController - .fetchEvents( - { - kinds: [0], - search: searchTerm - }, - uniqueReadRelaySet - ) + 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() @@ -212,15 +199,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.info('fineFilteredEvents', fineFilteredEvents) setFoundUsers(fineFilteredEvents) @@ -344,29 +331,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( @@ -376,7 +349,7 @@ export const CreatePage = () => { }) } }) - }, [metadata, users]) + }, [userProfiles, users, findMetadata]) useEffect(() => { if (usersPubkey) { @@ -931,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) @@ -1045,7 +1018,7 @@ export const CreatePage = () => { setUserSearchInput(value) } - const parseContent = (event: Event) => { + const parseContent = (event: NostrEvent) => { try { return JSON.parse(event.content) } catch (e) { @@ -1154,7 +1127,7 @@ export const CreatePage = () => { key={option.pubkey} > { > { 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/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/pages/profile/index.tsx b/src/pages/profile/index.tsx index 7a2f720..8e1e8c0 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 { NDKUserProfile } 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 metadataState = useAppSelector((state) => state.metadata) + const [userProfile, setUserProfile] = useState(null) + + const userRobotImage = useAppSelector((state) => state.user.robotImage) + const currentUserProfile = useAppSelector((state) => state.user.profile) 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,26 @@ 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) - } + if (isUsersOwnProfile && currentUserProfile) { + setUserProfile(currentUserProfile) + 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, currentUserProfile, pubkey, findMetadata]) /** * Rendering text with button which copies the provided text @@ -146,29 +113,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 +159,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 +198,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..57383a7 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,48 @@ 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, 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 { setUserProfile as updateUserProfile } 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 metadataState = useAppSelector((state) => state.metadata) + const [userProfile, setUserProfile] = useState(null) + + 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 ) - 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 +75,30 @@ 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 && currentUserProfile) { + setUserProfile(currentUserProfile) - if (isUsersOwnProfile && metadataState) { - const metadataContent = metadataController.extractProfileMetadataContent( - metadataState as VerifiedEvent - ) - if (metadataContent) { - setProfileMetadata(metadataContent) - setIsLoading(false) - } + 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, currentUserProfile, pubkey, findMetadata]) const editItem = ( - key: keyof ProfileMetadata, + key: keyof NDKUserProfile, label: string, multiline = false, rows = 1, @@ -145,7 +108,7 @@ export const ProfileSettingsPage = () => { { onChange={(event: React.ChangeEvent) => { const { value } = event.target - setProfileMetadata((prev) => ({ + setUserProfile((prev) => ({ ...prev, [key]: value })) @@ -197,34 +160,47 @@ 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) - dispatch(setMetadataEvent(signedEvent)) + // 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(updateUserProfile(userProfile)) } setSavingProfileMetadata(false) @@ -241,7 +217,7 @@ export const ProfileSettingsPage = () => { const robotAvatarLink = getRoboHashPicture(npub!, robotSet.current) - setProfileMetadata((prev) => ({ + setUserProfile((prev) => ({ ...prev, picture: robotAvatarLink })) @@ -267,14 +243,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 +276,7 @@ export const ProfileSettingsPage = () => { } > - {profileMetadata && ( + {userProfile && (
{ flexDirection: 'column' }} > - {profileMetadata.banner ? ( + {userProfile.banner ? ( Banner Image ) : ( @@ -334,32 +310,17 @@ 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, { + {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)} @@ -368,6 +329,7 @@ export const ProfileSettingsPage = () => { <> {usersPubkey && copyItem(nip19.npubEncode(usersPubkey), 'Public Key')} + {loginMethod === LoginMethod.privateKey && keys && keys.private && diff --git a/src/pages/settings/relays/index.tsx b/src/pages/settings/relays/index.tsx index a1f5223..b2c102e 100644 --- a/src/pages/settings/relays/index.tsx +++ b/src/pages/settings/relays/index.tsx @@ -13,26 +13,40 @@ 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 { capitalizeFirstLetter, compareObjects, - getRelayInfo, - getRelayMap, + getRelayMapFromNDKRelayList, hexToNpub, 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 +56,51 @@ 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 = getRelayMapFromNDKRelayList(ndkRelayList) + + 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 +128,8 @@ export const RelaysPage = () => { const relayMapPublishingRes = await publishRelayMap( relayMapCopy, usersPubkey, - [relay] + ndk, + publish ).catch((err) => handlePublishRelayMapError(err)) if (relayMapPublishingRes) { @@ -132,7 +176,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 +207,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 +218,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 +305,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/pages/sign/index.tsx b/src/pages/sign/index.tsx index fe9d047..8eb782e 100644 --- a/src/pages/sign/index.tsx +++ b/src/pages/sign/index.tsx @@ -32,12 +32,10 @@ import { parseJson, processMarks, readContentOfZipEntry, - sendNotification, signEventForMetaFile, timeout, unixNow, updateMarks, - updateUsersAppData, uploadMetaToFileStorage } from '../../utils' import { CurrentUserMark, Mark } from '../../types/mark.ts' @@ -49,12 +47,14 @@ import { } from '../../utils/file.ts' import { generateTimestamp } from '../../utils/opentimestamps.ts' import { MARK_TYPE_CONFIG } from '../../components/MarkTypeStrategy/MarkStrategy.tsx' +import { useNDK } from '../../hooks/useNDK.ts' import { getLastSignersSig } from '../../utils/sign.ts' export const SignPage = () => { const navigate = useNavigate() const location = useLocation() const params = useParams() + const { updateUsersAppData, sendNotification } = useNDK() const usersAppData = useAppSelector((state) => state.userAppData) @@ -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/sign/internal/displayMeta.tsx b/src/pages/sign/internal/displayMeta.tsx index 9335495..1da3b93 100644 --- a/src/pages/sign/internal/displayMeta.tsx +++ b/src/pages/sign/internal/displayMeta.tsx @@ -1,10 +1,12 @@ +import { useEffect, useState } from 'react' +import { toast } from 'react-toastify' + import { - Meta, - ProfileMetadata, - SignedEventContent, - User, - UserRole -} from '../../../types' + Cancel, + CheckCircle, + Download, + HourglassTop +} from '@mui/icons-material' import { Box, IconButton, @@ -20,22 +22,19 @@ import { Typography, useTheme } from '@mui/material' -import { - Download, - CheckCircle, - Cancel, - HourglassTop -} from '@mui/icons-material' + import saveAs from 'file-saver' -import { kinds, Event } from 'nostr-tools' -import { useState, useEffect } from 'react' -import { toast } from 'react-toastify' + +import { Event } from 'nostr-tools' + import { UserAvatar } from '../../../components/UserAvatar' -import { MetadataController } from '../../../controllers' -import { npubToHex, hexToNpub, parseJson } from '../../../utils' -import styles from '../style.module.scss' + +import { Meta, SignedEventContent, User, UserRole } from '../../../types' +import { hexToNpub, npubToHex, parseJson } from '../../../utils' import { SigitFile } from '../../../utils/file' +import styles from '../style.module.scss' + type DisplayMetaProps = { meta: Meta files: { [fileName: string]: SigitFile } @@ -67,9 +66,6 @@ export const DisplayMeta = ({ theme.palette.background.paper ) - const [metadata, setMetadata] = useState<{ [key: string]: ProfileMetadata }>( - {} - ) 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 diff --git a/src/pages/verify/index.tsx b/src/pages/verify/index.tsx index 2ea8164..e39ca44 100644 --- a/src/pages/verify/index.tsx +++ b/src/pages/verify/index.tsx @@ -21,9 +21,7 @@ import { readContentOfZipEntry, signEventForMetaFile, getCurrentUserFiles, - updateUsersAppData, npubToHex, - sendNotification, generateEncryptionKey, encryptArrayBuffer, generateKeysFile, @@ -35,7 +33,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, useNDK } from '../../hooks' import { getLastSignersSig } from '../../utils/sign.ts' import { saveAs } from 'file-saver' import { Container } from '../../components/Container' @@ -172,6 +170,7 @@ const SlimPdfView = ({ export const VerifyPage = () => { const location = useLocation() const params = useParams() + const { updateUsersAppData, sendNotification } = useNDK() const usersAppData = useAppSelector((state) => state.userAppData) const usersPubkey = useAppSelector((state) => state.auth.usersPubkey) @@ -354,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( 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..b48baca --- /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.user || initialState + + 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/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/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 deleted file mode 100644 index c2f33e8..0000000 --- a/src/utils/dvm.ts +++ /dev/null @@ -1,228 +0,0 @@ -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 { 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 -} - -/** - * 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/index.ts b/src/utils/index.ts index 791c39b..274ceab 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,5 +1,6 @@ +export * from './auth' +export * from './const' export * from './crypto' -export * from './dvm' export * from './hash' export * from './localStorage' export * from './mark' @@ -11,4 +12,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 474e8dc..600bd08 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, NDKUserProfile } 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,36 +17,16 @@ import { } from 'nostr-tools' import { toast } from 'react-toastify' import { NIP05_REGEX } from '../constants' -import { - MetadataController, - NostrController, - relayController -} from '../controllers' -import { - updateProcessedGiftWraps, - updateUserAppData as updateUserAppDataAction -} from '../store/actions' -import { Keys } from '../store/auth/types' import store from '../store/store' -import { - isSigitNotification, - Meta, - ProfileMetadata, - SigitNotification, - SignedEvent, - UserAppData -} from '../types' -import { getDefaultRelayMap } from './relays' -import { parseJson, removeLeadingSlash } from './string' -import { timeout } from './utils' -import { getHash } from './hash' +import { Meta, SignedEvent } from '../types' import { SIGIT_BLOSSOM } from './const.ts' -import { fetchMetaFromFileStorage } from './meta.ts' +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 @@ -206,27 +185,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 @@ -357,324 +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 (): Promise => { - // Get an instance of the NostrController - const nostrController = NostrController.getInstance() - - // 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! - - // Decryption can fail down in the code if extension options changed - // Forcefully log out the user if we detect missmatch between pubkeys - if (usersPubkey !== (await nostrController.capturePublicKey())) { - return null - } - - const relayMap = store.getState().relays?.map - - // 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 - }) - - // Return null if metadata retrieval failed - if (!relaySet) return null - - // Ensure that the relay list is not empty - if (relaySet.write.length === 0) return null - - // Add write relays to the relays array - relays.push(...relaySet.write) - } 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 - } - } - } - - // 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) @@ -709,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 @@ -777,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 @@ -846,186 +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) => { - // 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 - - // Ensure relay list is not empty - if (relaySet.read.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, relaySet.read) - 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 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 - let encryptionKey: string | undefined - if (!notification.keys) return - - const { sender, keys } = notification.keys - - // Retrieve the user's public key from the state - const usersPubkey = store.getState().auth.usersPubkey! - 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) - } catch (error) { - console.error(`An error occured fetching meta file from storage`, error) - return - } - } else { - meta = parsedContent - } - - await updateUsersAppData(meta) -} - -/** - * Function to send a notification to a specified receiver. - * @param receiver - The recipient's public key. - * @param notification - Url pointing to metadata associated with the notification on blossom and keys to decrypt. - */ -export const sendNotification = async ( - receiver: string, - notification: SigitNotification -) => { - // 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(notification), - tags: [], - created_at: unixNow() - } - - // 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 - - // Ensure relay list is not empty - if (relaySet.read.length === 0) return - - // Publish the notification event to the recipient's read relays - await Promise.race([ - relayController.publish(wrappedEvent, relaySet.read), - 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) @@ -1033,8 +497,29 @@ export const sendNotification = async ( */ export const getProfileUsername = ( npub: `npub1${string}` | string, - profile?: ProfileMetadata + profile?: NDKUserProfile ) => - truncate(profile?.display_name || profile?.name || hexToNpub(npub), { + truncate(profile?.displayName || 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 7a0ad56..bcb2e98 100644 --- a/src/utils/relays.ts +++ b/src/utils/relays.ts @@ -1,171 +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, 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: DEFAULT_LOOK_UP_RELAY_LIST, - write: DEFAULT_LOOK_UP_RELAY_LIST -}) - -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. @@ -173,10 +45,11 @@ 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, - extraRelaysToPublish?: string[] + ndk: NDK, + publish: (event: NDKEvent) => Promise ): Promise => { const timestamp = unixNow() const relayURIs = Object.keys(relayMap) @@ -205,21 +78,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( @@ -229,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 -}