diff --git a/src/contexts/NDKContext.tsx b/src/contexts/NDKContext.tsx index c4d73f9..61f856c 100644 --- a/src/contexts/NDKContext.tsx +++ b/src/contexts/NDKContext.tsx @@ -244,6 +244,9 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => { let ndkRelaySet: NDKRelaySet | undefined if (explicitRelayUrls && explicitRelayUrls.length > 0) { + if (!explicitRelayUrls.includes(SIGIT_RELAY)) { + explicitRelayUrls = [...explicitRelayUrls, SIGIT_RELAY] + } ndkRelaySet = NDKRelaySet.fromRelayUrls(explicitRelayUrls, ndk) } diff --git a/src/controllers/RelayController.ts b/src/controllers/RelayController.ts deleted file mode 100644 index ee66472..0000000 --- a/src/controllers/RelayController.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { Event, Filter, Relay } from 'nostr-tools' -import { - settleAllFullfilfedPromises, - normalizeWebSocketURL, - timeout -} from '../utils' -import { SIGIT_RELAY } from '../utils/const' - -/** - * Singleton class to manage relay operations. - */ -export class RelayController { - private static instance: RelayController - private pendingConnections = new Map>() // Track pending connections - public connectedRelays = new Map() - - private constructor() {} - - /** - * Provides the singleton instance of RelayController. - * - * @returns The singleton instance of RelayController. - */ - public static getInstance(): RelayController { - if (!RelayController.instance) { - RelayController.instance = new RelayController() - } - return RelayController.instance - } - - /** - * Connects to a relay server if not already connected. - * - * This method checks if a relay with the given URL is already in the list of connected relays. - * If it is not connected, it attempts to establish a new connection. - * On successful connection, the relay is added to the list of connected relays and returned. - * If the connection fails, an error is logged and `null` is returned. - * - * @param relayUrl - The URL of the relay server to connect to. - * @returns A promise that resolves to the connected relay object if successful, or `null` if the connection fails. - */ - public connectRelay = async (relayUrl: string): Promise => { - const normalizedWebSocketURL = normalizeWebSocketURL(relayUrl) - const relay = this.connectedRelays.get(normalizedWebSocketURL) - - if (relay) { - if (relay.connected) return relay - - // If relay is found in connectedRelay map but not connected, - // remove it from map and call connectRelay method again - this.connectedRelays.delete(relayUrl) - return this.connectRelay(relayUrl) - } - - // Check if there's already a pending connection for this relay URL - if (this.pendingConnections.has(relayUrl)) { - // Return the existing promise to avoid making another connection - return this.pendingConnections.get(relayUrl)! - } - - // Create a new connection promise and store it in pendingConnections - const connectionPromise = Relay.connect(relayUrl) - .then((relay) => { - if (relay.connected) { - // Add the newly connected relay to the connected relays map - this.connectedRelays.set(relayUrl, relay) - - // Return the newly connected relay - return relay - } - - return null - }) - .catch((err) => { - // Log an error message if the connection fails - console.error(`Relay connection failed: ${relayUrl}`, err) - - // Return null to indicate connection failure - return null - }) - .finally(() => { - // Remove the connection from pendingConnections once it settles - this.pendingConnections.delete(relayUrl) - }) - - this.pendingConnections.set(relayUrl, connectionPromise) - return connectionPromise - } - - /** - * Asynchronously retrieves multiple event from a set of relays based on a provided filter. - * If no relays are specified, it defaults to using connected relays. - * - * @param filter - The filter criteria to find the event. - * @param relays - An optional array of relay URLs to search for the event. - * @returns Returns a promise that resolves with an array of events. - */ - fetchEvents = async ( - filter: Filter, - relayUrls: string[] = [] - ): Promise => { - if (!relayUrls.includes(SIGIT_RELAY)) { - /** - * NOTE: To avoid side-effects on external relayUrls array passed as argument - * re-assigned relayUrls with added sigit relay instead of just appending to same array - */ - - relayUrls = [...relayUrls, SIGIT_RELAY] // Add app relay to relays array if not exists already - } - - // connect to all specified relays - const relays = await settleAllFullfilfedPromises( - relayUrls, - this.connectRelay - ) - - // Check if any relays are connected - if (relays.length === 0) { - throw new Error('No relay is connected to fetch events!') - } - - const events: Event[] = [] - const eventIds = new Set() // To keep track of event IDs and avoid duplicates - - // Create a promise for each relay subscription - const subPromises = relays.map((relay) => { - return new Promise((resolve) => { - if (!relay.connected) { - console.log(`${relay.url} : Not connected!`, 'Skipping subscription') - return resolve() - } - - // Subscribe to the relay with the specified filter - const sub = relay.subscribe([filter], { - // Handle incoming events - onevent: (e) => { - // Add the event to the array if it's not a duplicate - if (!eventIds.has(e.id)) { - eventIds.add(e.id) // Record the event ID - events.push(e) // Add the event to the array - } - }, - // Handle the End-Of-Stream (EOSE) message - oneose: () => { - sub.close() // Close the subscription - resolve() // Resolve the promise when EOSE is received - } - }) - - // add a 30 sec of timeout to subscription - setTimeout(() => { - if (!sub.closed) { - sub.close() - resolve() - } - }, 30 * 1000) - }) - }) - - // Wait for all subscriptions to complete - await Promise.allSettled(subPromises) - - // It is possible that different relays will send different events and events array may contain more events then specified limit in filter - // To fix this issue we'll first sort these events and then return only limited events - if (filter.limit) { - // Sort events by creation date in descending order - events.sort((a, b) => b.created_at - a.created_at) - - return events.slice(0, filter.limit) - } - - return events - } - - /** - * Asynchronously retrieves an event from a set of relays based on a provided filter. - * If no relays are specified, it defaults to using connected relays. - * - * @param filter - The filter criteria to find the event. - * @param relays - An optional array of relay URLs to search for the event. - * @returns Returns a promise that resolves to the found event or null if not found. - */ - fetchEvent = async ( - filter: Filter, - relays: string[] = [] - ): Promise => { - const events = await this.fetchEvents(filter, relays) - - // Sort events by creation date in descending order - events.sort((a, b) => b.created_at - a.created_at) - - // Return the most recent event, or null if no events were received - return events[0] || null - } - - publish = async ( - event: Event, - relayUrls: string[] = [] - ): Promise => { - if (!relayUrls.includes(SIGIT_RELAY)) { - /** - * NOTE: To avoid side-effects on external relayUrls array passed as argument - * re-assigned relayUrls with added sigit relay instead of just appending to same array - */ - relayUrls = [...relayUrls, SIGIT_RELAY] // Add app relay to relays array if not exists already - } - - // connect to all specified relays - const relays = await settleAllFullfilfedPromises( - relayUrls, - this.connectRelay - ) - - // Check if any relays are connected - if (relays.length === 0) { - throw new Error('No relay is connected to publish event!') - } - - const publishedOnRelays: string[] = [] // List to track which relays successfully published the event - - // Create a promise for publishing the event to each connected relay - const publishPromises = relays.map(async (relay) => { - try { - await Promise.race([ - relay.publish(event), // Publish the event to the relay - timeout(20 * 1000) // Set a timeout to handle cases where publishing takes too long - ]) - publishedOnRelays.push(relay.url) // Add the relay URL to the list of successfully published relays - } catch (err) { - console.error(`Failed to publish event on relay: ${relay.url}`, err) - } - }) - - // Wait for all publish operations to complete (either fulfilled or rejected) - await Promise.allSettled(publishPromises) - - // Return the list of relay URLs where the event was published - return publishedOnRelays - } -} - -export const relayController = RelayController.getInstance() diff --git a/src/controllers/index.ts b/src/controllers/index.ts index 63c9671..e7302ce 100644 --- a/src/controllers/index.ts +++ b/src/controllers/index.ts @@ -1,2 +1 @@ export * from './NostrController' -export * from './RelayController' diff --git a/src/hooks/index.ts b/src/hooks/index.ts index e0c77b0..cbadfee 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -3,4 +3,5 @@ export * from './useAuth' export * from './useDidMount' export * from './useDvm' export * from './useLogout' +export * from './useNDK' export * from './useNDKContext' diff --git a/src/hooks/useNDK.ts b/src/hooks/useNDK.ts new file mode 100644 index 0000000..5e24352 --- /dev/null +++ b/src/hooks/useNDK.ts @@ -0,0 +1,415 @@ +import { useCallback } from 'react' +import { toast } from 'react-toastify' + +import { bytesToHex } from '@noble/hashes/utils' +import { NDKEvent, NDKFilter, NDKKind } from '@nostr-dev-kit/ndk' +import _ from 'lodash' +import { + Event, + generateSecretKey, + getPublicKey, + kinds, + UnsignedEvent +} from 'nostr-tools' + +import { useAppDispatch, useAppSelector, useNDKContext } from '.' +import { NostrController } from '../controllers' +import { + updateProcessedGiftWraps, + updateUserAppData as updateUserAppDataAction +} from '../store/actions' +import { Keys } from '../store/auth/types' +import { Meta, UserAppData, UserRelaysType } from '../types' +import { + countLeadingZeroes, + createWrap, + deleteBlossomFile, + getDTagForUserAppData, + getUserAppDataFromBlossom, + hexToNpub, + parseJson, + unixNow, + uploadUserAppDataToBlossom +} from '../utils' + +export const useNDK = () => { + const dispatch = useAppDispatch() + const { ndk, fetchEvent, fetchEventsFromUserRelays, publish } = + useNDKContext() + const usersPubkey = useAppSelector((state) => state.auth.usersPubkey) + const appData = useAppSelector((state) => state.userAppData) + const processedEvents = useAppSelector( + (state) => state.userAppData?.processedGiftWraps + ) + + /** + * Fetches user application data based on user's public key. + * + * @returns The user application data or null if an error occurs or no data is found. + */ + const getUsersAppData = useCallback(async (): Promise => { + if (!usersPubkey) return null + + // Generate an identifier for the user's nip78 + const dTag = await getDTagForUserAppData() + if (!dTag) return null + + // Define a filter for fetching events + const filter: NDKFilter = { + kinds: [NDKKind.AppSpecificData], + authors: [usersPubkey], + '#d': [dTag] + } + + const encryptedContent = await fetchEvent(filter) + .then((event) => { + if (event) return event.content + + // If no event is found, return an empty stringified object + return '{}' + }) + .catch((err) => { + // Log error and show a toast notification if fetching event fails + console.log(`An error occurred in finding kind 30078 event`, err) + toast.error( + 'An error occurred in finding kind 30078 event for data storage' + ) + return null + }) + + // Return null if encrypted content retrieval fails + if (!encryptedContent) return null + + // Handle case where the encrypted content is an empty object + if (encryptedContent === '{}') { + // Generate ephemeral key pair + const secret = generateSecretKey() + const pubKey = getPublicKey(secret) + + return { + sigits: {}, + processedGiftWraps: [], + blossomUrls: [], + keyPair: { + private: bytesToHex(secret), + public: pubKey + } + } + } + + // Get an instance of the NostrController + const nostrController = NostrController.getInstance() + + // Decrypt the encrypted content + const decrypted = await nostrController + .nip04Decrypt(usersPubkey, encryptedContent) + .catch((err) => { + // Log error and show a toast notification if decryption fails + console.log('An error occurred while decrypting app data', err) + toast.error('An error occurred while decrypting app data') + return null + }) + + // Return null if decryption fails + if (!decrypted) return null + + // Parse the decrypted content + const parsedContent = await parseJson<{ + blossomUrls: string[] + keyPair: Keys + }>(decrypted).catch((err) => { + // Log error and show a toast notification if parsing fails + console.log( + 'An error occurred in parsing the content of kind 30078 event', + err + ) + toast.error( + 'An error occurred in parsing the content of kind 30078 event' + ) + return null + }) + + // Return null if parsing fails + if (!parsedContent) return null + + const { blossomUrls, keyPair } = parsedContent + + // Return null if no blossom URLs are found + if (blossomUrls.length === 0) return null + + // Fetch additional user app data from the first blossom URL + const dataFromBlossom = await getUserAppDataFromBlossom( + blossomUrls[0], + keyPair.private + ) + + // Return null if fetching data from blossom fails + if (!dataFromBlossom) return null + + const { sigits, processedGiftWraps } = dataFromBlossom + + // Return the final user application data + return { + blossomUrls, + keyPair, + sigits, + processedGiftWraps + } + }, [usersPubkey, fetchEvent]) + + const updateUsersAppData = useCallback( + async (meta: Meta) => { + if (!appData || !appData.keyPair || !usersPubkey) return null + + const sigits = _.cloneDeep(appData.sigits) + + const createSignatureEvent = await parseJson( + meta.createSignature + ).catch((err) => { + console.log('err in parsing the createSignature event:>> ', err) + toast.error( + err.message || 'error occurred in parsing the create signature event' + ) + return null + }) + + if (!createSignatureEvent) return null + + const id = createSignatureEvent.id + let isUpdated = false + + // check if sigit already exists + if (id in sigits) { + // update meta only if incoming meta is more recent + // than already existing one + const existingMeta = sigits[id] + if (existingMeta.modifiedAt < meta.modifiedAt) { + sigits[id] = meta + isUpdated = true + } + } else { + sigits[id] = meta + isUpdated = true + } + + if (!isUpdated) return null + + const blossomUrls = [...appData.blossomUrls] + + const newBlossomUrl = await uploadUserAppDataToBlossom( + sigits, + appData.processedGiftWraps, + appData.keyPair.private + ).catch((err) => { + console.log( + 'An error occurred in uploading user app data file to blossom server', + err + ) + toast.error( + 'An error occurred in uploading user app data file to blossom server' + ) + return null + }) + + if (!newBlossomUrl) return null + + // insert new blossom url at the start of the array + blossomUrls.unshift(newBlossomUrl) + + // only keep last 10 blossom urls, delete older ones + if (blossomUrls.length > 10) { + const filesToDelete = blossomUrls.splice(10) + filesToDelete.forEach((url) => { + deleteBlossomFile(url, appData.keyPair!.private).catch((err) => { + console.log( + 'An error occurred in removing old file of user app data from blossom server', + err + ) + }) + }) + } + + // encrypt content for storing in kind 30078 event + const nostrController = NostrController.getInstance() + const encryptedContent = await nostrController + .nip04Encrypt( + usersPubkey, + JSON.stringify({ + blossomUrls, + keyPair: appData.keyPair + }) + ) + .catch((err) => { + console.log( + 'An error occurred in encryption of content for app data', + err + ) + toast.error( + err.message || + 'An error occurred in encryption of content for app data' + ) + return null + }) + + if (!encryptedContent) return null + + // generate the identifier for user's appData event + const dTag = await getDTagForUserAppData() + if (!dTag) return null + + const updatedEvent: UnsignedEvent = { + kind: kinds.Application, + pubkey: usersPubkey, + created_at: unixNow(), + tags: [['d', dTag]], + content: encryptedContent + } + + const signedEvent = await nostrController + .signEvent(updatedEvent) + .catch((err) => { + console.log('An error occurred in signing event', err) + toast.error(err.message || 'An error occurred in signing event') + return null + }) + + if (!signedEvent) return null + + const ndkEvent = new NDKEvent(ndk, signedEvent) + const publishResult = await publish(ndkEvent) + + if (publishResult.length === 0) { + toast.error( + 'An unexpected error occurred in publishing updated app data ' + ) + return null + } + + if (!publishResult) return null + + // update redux store + dispatch( + updateUserAppDataAction({ + sigits, + blossomUrls, + processedGiftWraps: [...appData.processedGiftWraps], + keyPair: { + ...appData.keyPair + } + }) + ) + + return signedEvent + }, + [appData, dispatch, ndk, publish, usersPubkey] + ) + + const processReceivedEvent = useCallback( + async (event: NDKEvent, difficulty: number = 5) => { + // Abort processing if userAppData is undefined + if (!processedEvents) return + + if (processedEvents.includes(event.id)) return + + dispatch(updateProcessedGiftWraps([...processedEvents, event.id])) + + // validate PoW + // Count the number of leading zero bits in the hash + const leadingZeroes = countLeadingZeroes(event.id) + if (leadingZeroes < difficulty) return + + // decrypt the content of gift wrap event + const nostrController = NostrController.getInstance() + const decrypted = await nostrController.nip44Decrypt( + event.pubkey, + event.content + ) + + const internalUnsignedEvent = await parseJson( + decrypted + ).catch((err) => { + console.log( + 'An error occurred in parsing the internal unsigned event', + err + ) + return null + }) + + if (!internalUnsignedEvent || internalUnsignedEvent.kind !== 938) return + + const meta = await parseJson(internalUnsignedEvent.content).catch( + (err) => { + console.log( + 'An error occurred in parsing the internal unsigned event', + err + ) + return null + } + ) + + if (!meta) return + + await updateUsersAppData(meta) + }, + [dispatch, processedEvents, updateUsersAppData] + ) + + const subscribeForSigits = useCallback( + async (pubkey: string) => { + // Define the filter for the subscription + const filter: NDKFilter = { + kinds: [1059 as NDKKind], + '#p': [pubkey] + } + + // Process the received event synchronously + const events = await fetchEventsFromUserRelays( + filter, + pubkey, + UserRelaysType.Read + ) + + for (const e of events) { + await processReceivedEvent(e) + } + }, + [fetchEventsFromUserRelays, processReceivedEvent] + ) + + const sendNotification = useCallback( + async (receiver: string, meta: Meta) => { + if (!usersPubkey) return + + // Create an unsigned event object with the provided metadata + const unsignedEvent: UnsignedEvent = { + kind: 938, + pubkey: usersPubkey, + content: JSON.stringify(meta), + tags: [], + created_at: unixNow() + } + + // Wrap the unsigned event with the receiver's information + const wrappedEvent = createWrap(unsignedEvent, receiver) + + // Publish the notification event to the recipient's read relays + const ndkEvent = new NDKEvent(ndk, wrappedEvent) + await publish(ndkEvent).catch((err) => { + // Log an error if publishing the notification event fails + console.log( + `An error occurred while publishing notification event for ${hexToNpub(receiver)}`, + err + ) + throw err + }) + }, + [ndk, publish, usersPubkey] + ) + + return { + getUsersAppData, + subscribeForSigits, + updateUsersAppData, + sendNotification + } +} diff --git a/src/layouts/Main.tsx b/src/layouts/Main.tsx index 4409224..6d27ae8 100644 --- a/src/layouts/Main.tsx +++ b/src/layouts/Main.tsx @@ -16,6 +16,7 @@ import { useAppSelector, useAuth, useLogout, + useNDK, useNDKContext } from '../hooks' @@ -30,12 +31,7 @@ import { import { LoginMethod } from '../store/auth/types' import { setUserRobotImage } from '../store/userRobotImage/action' -import { - getRoboHashPicture, - getUsersAppData, - loadState, - subscribeForSigits -} from '../utils' +import { getRoboHashPicture, loadState } from '../utils' import styles from './style.module.scss' @@ -44,8 +40,9 @@ export const MainLayout = () => { const navigate = useNavigate() const dispatch = useAppDispatch() const logout = useLogout() - const { findMetadata, getNDKRelayList } = useNDKContext() + const { findMetadata } = useNDKContext() const { authAndGetMetadataAndRelaysMap } = useAuth() + const { getUsersAppData, subscribeForSigits } = useNDK() const [isLoading, setIsLoading] = useState(true) const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState(`Loading App`) @@ -191,13 +188,13 @@ export const MainLayout = () => { if (pubkey && !hasSubscribed.current) { // Call `subscribeForSigits` only if it hasn't been called before // #193 disabled websocket subscribtion, until #194 is done - subscribeForSigits(pubkey, getNDKRelayList) + subscribeForSigits(pubkey) // Mark `subscribeForSigits` as called hasSubscribed.current = true } } - }, [authState, isLoggedIn, usersAppData, getNDKRelayList]) + }, [authState, isLoggedIn, usersAppData, subscribeForSigits]) /** * When authState change user logged in / or app reloaded @@ -214,7 +211,7 @@ export const MainLayout = () => { setIsLoading(true) setLoadingSpinnerDesc(`Loading SIGit history...`) - getUsersAppData(getNDKRelayList) + getUsersAppData() .then((appData) => { if (appData) { dispatch(updateUserAppData(appData)) diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index b2d6f5d..f593cbd 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -10,7 +10,6 @@ import { import type { Identifier, XYCoord } from 'dnd-core' import saveAs from 'file-saver' import JSZip from 'jszip' -import { Event } from 'nostr-tools' import { useCallback, useEffect, useRef, useState } from 'react' import { DndProvider, useDrag, useDrop } from 'react-dnd' import { MultiBackend } from 'react-dnd-multi-backend' @@ -20,7 +19,7 @@ import { useLocation, useNavigate } from 'react-router-dom' import { toast } from 'react-toastify' import { LoadingSpinner } from '../../components/LoadingSpinner' import { UserAvatar } from '../../components/UserAvatar' -import { NostrController, RelayController } from '../../controllers' +import { NostrController } from '../../controllers' import { appPrivateRoutes } from '../../routes' import { CreateSignatureEventContent, @@ -28,6 +27,7 @@ import { Meta, SignedEvent, User, + UserRelaysType, UserRole } from '../../types' import { @@ -42,9 +42,7 @@ import { unixNow, npubToHex, queryNip05, - sendNotification, signEventForMetaFile, - updateUsersAppData, uploadToFileStorage, DEFAULT_TOOLBOX, settleAllFullfilfedPromises @@ -75,15 +73,17 @@ import { Autocomplete } from '@mui/lab' import _, { truncate } from 'lodash' import * as React from 'react' import { AvatarIconButton } from '../../components/UserAvatarIconButton' -import { NDKUserProfile } from '@nostr-dev-kit/ndk' +import { NDKUserProfile, NostrEvent } from '@nostr-dev-kit/ndk' import { useNDKContext } from '../../hooks/useNDKContext.ts' +import { useNDK } from '../../hooks/useNDK.ts' -type FoundUser = Event & { npub: string } +type FoundUser = NostrEvent & { npub: string } export const CreatePage = () => { const navigate = useNavigate() const location = useLocation() - const { findMetadata, getNDKRelayList } = useNDKContext() + const { findMetadata, fetchEventsFromUserRelays } = useNDKContext() + const { updateUsersAppData, sendNotification } = useNDK() const { uploadedFiles } = location.state || {} const [currentFile, setCurrentFile] = useState() @@ -155,24 +155,20 @@ export const CreatePage = () => { setSearchUsersLoading(true) - const relayController = RelayController.getInstance() - const searchTerm = searchString.trim() - const ndkRelayList = await getNDKRelayList(usersPubkey) - - relayController - .fetchEvents( - { - kinds: [0], - search: searchTerm - }, - [...ndkRelayList.writeRelayUrls] - ) + fetchEventsFromUserRelays( + { + kinds: [0], + search: searchTerm + }, + usersPubkey, + UserRelaysType.Write + ) .then((events) => { - console.log('events', events) + const nostrEvents = events.map((event) => event.rawEvent()) - const fineFilteredEvents: FoundUser[] = events + const fineFilteredEvents = nostrEvents .filter((event) => { const lowercaseContent = event.content.toLowerCase() @@ -189,15 +185,15 @@ export const CreatePage = () => { lowercaseContent.includes(`"nip05":"${searchTerm.toLowerCase()}"`) ) }) - .reduce((uniqueEvents: FoundUser[], event: Event) => { - if (!uniqueEvents.some((e: Event) => e.pubkey === event.pubkey)) { + .reduce((uniqueEvents, event) => { + if (!uniqueEvents.some((e) => e.pubkey === event.pubkey)) { uniqueEvents.push({ ...event, npub: hexToNpub(event.pubkey) }) } return uniqueEvents - }, []) + }, [] as FoundUser[]) console.log('fineFilteredEvents', fineFilteredEvents) setFoundUsers(fineFilteredEvents) @@ -773,9 +769,7 @@ export const CreatePage = () => { : viewers.map((viewer) => viewer.pubkey) ).filter((receiver) => receiver !== usersPubkey) - return receivers.map((receiver) => - sendNotification(receiver, meta, getNDKRelayList) - ) + return receivers.map((receiver) => sendNotification(receiver, meta)) } const extractNostrId = (stringifiedEvent: string): string => { @@ -965,12 +959,11 @@ export const CreatePage = () => { setUserSearchInput(value) } - const parseContent = (event: Event) => { + const parseContent = (event: NostrEvent) => { try { return JSON.parse(event.content) } catch (e) { return undefined - console.error(e) } } diff --git a/src/pages/settings/relays/index.tsx b/src/pages/settings/relays/index.tsx index ba776fb..b2c102e 100644 --- a/src/pages/settings/relays/index.tsx +++ b/src/pages/settings/relays/index.tsx @@ -21,17 +21,12 @@ import { useNDKContext } from '../../../hooks' import { setRelayMapAction } from '../../../store/actions' -import { - RelayConnectionState, - RelayFee, - RelayInfo, - RelayMap -} from '../../../types' +import { RelayConnectionState, RelayFee, RelayInfo } from '../../../types' import { capitalizeFirstLetter, compareObjects, + getRelayMapFromNDKRelayList, hexToNpub, - normalizeWebSocketURL, publishRelayMap, shorten, timeout @@ -85,30 +80,7 @@ export const RelaysPage = () => { // new relay map useEffect(() => { if (ndkRelayList) { - const newRelayMap: RelayMap = {} - - ndkRelayList.readRelayUrls.forEach((relayUrl) => { - const normalizedUrl = normalizeWebSocketURL(relayUrl) - - newRelayMap[normalizedUrl] = { - read: true, - write: false - } - }) - - ndkRelayList.writeRelayUrls.forEach((relayUrl) => { - const normalizedUrl = normalizeWebSocketURL(relayUrl) - - const existing = newRelayMap[normalizedUrl] - if (existing) { - existing.write = true - } else { - newRelayMap[normalizedUrl] = { - read: false, - write: true - } - } - }) + const newRelayMap = getRelayMapFromNDKRelayList(ndkRelayList) if (!compareObjects(relayMap, newRelayMap)) { dispatch(setRelayMapAction(newRelayMap)) diff --git a/src/pages/sign/index.tsx b/src/pages/sign/index.tsx index f874122..3adea40 100644 --- a/src/pages/sign/index.tsx +++ b/src/pages/sign/index.tsx @@ -29,9 +29,7 @@ import { npubToHex, parseJson, readContentOfZipEntry, - sendNotification, signEventForMetaFile, - updateUsersAppData, findOtherUserMarks, timeout, processMarks @@ -56,7 +54,7 @@ import { import { ARRAY_BUFFER, DEFLATE } from '../../utils/const.ts' import { generateTimestamp } from '../../utils/opentimestamps.ts' import { MARK_TYPE_CONFIG } from '../../components/MarkTypeStrategy/MarkStrategy.tsx' -import { useNDKContext } from '../../hooks/useNDKContext.ts' +import { useNDK } from '../../hooks/useNDK.ts' enum SignedStatus { Fully_Signed, @@ -68,7 +66,7 @@ export const SignPage = () => { const navigate = useNavigate() const location = useLocation() const params = useParams() - const { getNDKRelayList } = useNDKContext() + const { updateUsersAppData, sendNotification } = useNDK() const usersAppData = useAppSelector((state) => state.userAppData) @@ -783,7 +781,7 @@ export const SignPage = () => { setLoadingSpinnerDesc('Sending notifications') const users = Array.from(userSet) const promises = users.map((user) => - sendNotification(npubToHex(user)!, meta, getNDKRelayList) + sendNotification(npubToHex(user)!, meta) ) await Promise.all(promises) .then(() => { diff --git a/src/pages/verify/index.tsx b/src/pages/verify/index.tsx index 282390e..6dc5180 100644 --- a/src/pages/verify/index.tsx +++ b/src/pages/verify/index.tsx @@ -21,15 +21,13 @@ import { readContentOfZipEntry, signEventForMetaFile, getCurrentUserFiles, - updateUsersAppData, - npubToHex, - sendNotification + npubToHex } from '../../utils' import styles from './style.module.scss' import { useLocation, useParams } from 'react-router-dom' import axios from 'axios' import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf.ts' -import { useAppSelector, useNDKContext } from '../../hooks' +import { useAppSelector, useNDK } from '../../hooks' import { getLastSignersSig } from '../../utils/sign.ts' import { saveAs } from 'file-saver' import { Container } from '../../components/Container' @@ -166,7 +164,7 @@ const SlimPdfView = ({ export const VerifyPage = () => { const location = useLocation() const params = useParams() - const { getNDKRelayList } = useNDKContext() + const { updateUsersAppData, sendNotification } = useNDK() const usersAppData = useAppSelector((state) => state.userAppData) const usersPubkey = useAppSelector((state) => state.auth.usersPubkey) @@ -365,7 +363,7 @@ export const VerifyPage = () => { const users = Array.from(userSet) const promises = users.map((user) => - sendNotification(npubToHex(user)!, updatedMeta, getNDKRelayList) + sendNotification(npubToHex(user)!, updatedMeta) ) await Promise.all(promises) diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index ed82513..ad33ab8 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -1,16 +1,15 @@ -import { bytesToHex, hexToBytes } from '@noble/hashes/utils' +import { hexToBytes } from '@noble/hashes/utils' +import { NDKEvent } from '@nostr-dev-kit/ndk' import axios from 'axios' -import _, { truncate } from 'lodash' +import { truncate } from 'lodash' import { Event, EventTemplate, - Filter, UnsignedEvent, finalizeEvent, generateSecretKey, getEventHash, getPublicKey, - kinds, nip04, nip19, nip44, @@ -18,25 +17,16 @@ import { } from 'nostr-tools' import { toast } from 'react-toastify' import { NIP05_REGEX } from '../constants' -import { NostrController, relayController } from '../controllers' -import { - updateProcessedGiftWraps, - updateUserAppData as updateUserAppDataAction -} from '../store/actions' -import { Keys } from '../store/auth/types' import store from '../store/store' -import { Meta, ProfileMetadata, SignedEvent, UserAppData } from '../types' -import { getDefaultRelayMap } from './relays' -import { parseJson, removeLeadingSlash } from './string' -import { timeout } from './utils' -import { getHash } from './hash' +import { Meta, ProfileMetadata, SignedEvent } from '../types' import { SIGIT_BLOSSOM } from './const.ts' -import { Hexpubkey, NDKEvent, NDKRelayList } from '@nostr-dev-kit/ndk' +import { getHash } from './hash' +import { parseJson, removeLeadingSlash } from './string' /** * Generates a `d` tag for userAppData */ -const getDTagForUserAppData = async (): Promise => { +export const getDTagForUserAppData = async (): Promise => { const isLoggedIn = store.getState().auth.loggedIn const pubkey = store.getState().auth?.usersPubkey @@ -325,309 +315,7 @@ export const createWrap = (unsignedEvent: UnsignedEvent, receiver: string) => { } } -/** - * Fetches user application data based on user's public key and stored metadata. - * - * @returns The user application data or null if an error occurs or no data is found. - */ -export const getUsersAppData = async ( - getNDKRelayList: (pubkey: Hexpubkey) => Promise -): Promise => { - // Initialize an array to hold relay URLs - const relays: string[] = [] - - // Retrieve the user's public key and relay map from the Redux store - const usersPubkey = store.getState().auth.usersPubkey! - const relayMap = store.getState().relays?.map - - // Check if relayMap is undefined in the Redux store - if (!relayMap) { - // If relayMap is not present, get relay list using NDKContext - - const ndkRelayList = await getNDKRelayList(usersPubkey) - - // Ensure that the relay list is not empty - if (ndkRelayList.writeRelayUrls.length === 0) return null - - // Add write relays to the relays array - relays.push(...ndkRelayList.writeRelayUrls) - - // // Ensure that the relay list is not empty - } else { - // If relayMap exists, filter and add write relays from the stored map - const writeRelays = Object.keys(relayMap).filter( - (key) => relayMap[key].write - ) - relays.push(...writeRelays) - } - - // Generate an identifier for the user's nip78 - const dTag = await getDTagForUserAppData() - if (!dTag) return null - - // Define a filter for fetching events - const filter: Filter = { - kinds: [kinds.Application], - '#d': [dTag] - } - - const encryptedContent = await relayController - .fetchEvent(filter, relays) - .then((event) => { - if (event) return event.content - - // If no event is found, return an empty stringified object - return '{}' - }) - .catch((err) => { - // Log error and show a toast notification if fetching event fails - console.log(`An error occurred in finding kind 30078 event`, err) - toast.error( - 'An error occurred in finding kind 30078 event for data storage' - ) - return null - }) - - // Return null if encrypted content retrieval fails - if (!encryptedContent) return null - - // Handle case where the encrypted content is an empty object - if (encryptedContent === '{}') { - // Generate ephemeral key pair - const secret = generateSecretKey() - const pubKey = getPublicKey(secret) - - return { - sigits: {}, - processedGiftWraps: [], - blossomUrls: [], - keyPair: { - private: bytesToHex(secret), - public: pubKey - } - } - } - - // Get an instance of the NostrController - const nostrController = NostrController.getInstance() - - // Decrypt the encrypted content - const decrypted = await nostrController - .nip04Decrypt(usersPubkey, encryptedContent) - .catch((err) => { - // Log error and show a toast notification if decryption fails - console.log('An error occurred while decrypting app data', err) - toast.error('An error occurred while decrypting app data') - return null - }) - - // Return null if decryption fails - if (!decrypted) return null - - // Parse the decrypted content - const parsedContent = await parseJson<{ - blossomUrls: string[] - keyPair: Keys - }>(decrypted).catch((err) => { - // Log error and show a toast notification if parsing fails - console.log( - 'An error occurred in parsing the content of kind 30078 event', - err - ) - toast.error('An error occurred in parsing the content of kind 30078 event') - return null - }) - - // Return null if parsing fails - if (!parsedContent) return null - - const { blossomUrls, keyPair } = parsedContent - - // Return null if no blossom URLs are found - if (blossomUrls.length === 0) return null - - // Fetch additional user app data from the first blossom URL - const dataFromBlossom = await getUserAppDataFromBlossom( - blossomUrls[0], - keyPair.private - ) - - // Return null if fetching data from blossom fails - if (!dataFromBlossom) return null - - const { sigits, processedGiftWraps } = dataFromBlossom - - // Return the final user application data - return { - blossomUrls, - keyPair, - sigits, - processedGiftWraps - } -} - -export const updateUsersAppData = async (meta: Meta) => { - const appData = store.getState().userAppData - if (!appData || !appData.keyPair) return null - - const sigits = _.cloneDeep(appData.sigits) - - const createSignatureEvent = await parseJson( - meta.createSignature - ).catch((err) => { - console.log('err in parsing the createSignature event:>> ', err) - toast.error( - err.message || 'error occurred in parsing the create signature event' - ) - return null - }) - - if (!createSignatureEvent) return null - - const id = createSignatureEvent.id - let isUpdated = false - - // check if sigit already exists - if (id in sigits) { - // update meta only if incoming meta is more recent - // than already existing one - const existingMeta = sigits[id] - if (existingMeta.modifiedAt < meta.modifiedAt) { - sigits[id] = meta - isUpdated = true - } - } else { - sigits[id] = meta - isUpdated = true - } - - if (!isUpdated) return null - - const blossomUrls = [...appData.blossomUrls] - - const newBlossomUrl = await uploadUserAppDataToBlossom( - sigits, - appData.processedGiftWraps, - appData.keyPair.private - ).catch((err) => { - console.log( - 'An error occurred in uploading user app data file to blossom server', - err - ) - toast.error( - 'An error occurred in uploading user app data file to blossom server' - ) - return null - }) - - if (!newBlossomUrl) return null - - // insert new blossom url at the start of the array - blossomUrls.unshift(newBlossomUrl) - - // only keep last 10 blossom urls, delete older ones - if (blossomUrls.length > 10) { - const filesToDelete = blossomUrls.splice(10) - filesToDelete.forEach((url) => { - deleteBlossomFile(url, appData.keyPair!.private).catch((err) => { - console.log( - 'An error occurred in removing old file of user app data from blossom server', - err - ) - }) - }) - } - - const usersPubkey = store.getState().auth.usersPubkey! - - // encrypt content for storing in kind 30078 event - const nostrController = NostrController.getInstance() - const encryptedContent = await nostrController - .nip04Encrypt( - usersPubkey, - JSON.stringify({ - blossomUrls, - keyPair: appData.keyPair - }) - ) - .catch((err) => { - console.log( - 'An error occurred in encryption of content for app data', - err - ) - toast.error( - err.message || 'An error occurred in encryption of content for app data' - ) - return null - }) - - if (!encryptedContent) return null - - // generate the identifier for user's appData event - const dTag = await getDTagForUserAppData() - if (!dTag) return null - - const updatedEvent: UnsignedEvent = { - kind: kinds.Application, - pubkey: usersPubkey!, - created_at: unixNow(), - tags: [['d', dTag]], - content: encryptedContent - } - - const signedEvent = await nostrController - .signEvent(updatedEvent) - .catch((err) => { - console.log('An error occurred in signing event', err) - toast.error(err.message || 'An error occurred in signing event') - return null - }) - - if (!signedEvent) return null - - const relayMap = store.getState().relays.map || getDefaultRelayMap() - const writeRelays = Object.keys(relayMap).filter((key) => relayMap[key].write) - - const publishResult = await Promise.race([ - relayController.publish(signedEvent, writeRelays), - timeout(40 * 1000) - ]).catch((err) => { - console.log('err :>> ', err) - if (err.message === 'Timeout') { - toast.error('Timeout occurred in publishing updated app data') - } else if (Array.isArray(err)) { - err.forEach((errResult) => { - toast.error( - `Publishing to ${errResult.relay} caused the following error: ${errResult.error}` - ) - }) - } else { - toast.error( - 'An unexpected error occurred in publishing updated app data ' - ) - } - - return null - }) - - if (!publishResult) return null - - // update redux store - store.dispatch( - updateUserAppDataAction({ - sigits, - blossomUrls, - processedGiftWraps: [...appData.processedGiftWraps], - keyPair: { - ...appData.keyPair - } - }) - ) - - return signedEvent -} - -const deleteBlossomFile = async (url: string, privateKey: string) => { +export const deleteBlossomFile = async (url: string, privateKey: string) => { const pathname = new URL(url).pathname const hash = removeLeadingSlash(pathname) @@ -662,7 +350,7 @@ const deleteBlossomFile = async (url: string, privateKey: string) => { * @param privateKey - The private key used for encryption. * @returns A promise that resolves to the URL of the uploaded file. */ -const uploadUserAppDataToBlossom = async ( +export const uploadUserAppDataToBlossom = async ( sigits: { [key: string]: Meta }, processedGiftWraps: string[], privateKey: string @@ -730,7 +418,10 @@ const uploadUserAppDataToBlossom = async ( * @param privateKey - The private key used for decryption. * @returns A promise that resolves to the decrypted and parsed user application data. */ -const getUserAppDataFromBlossom = async (url: string, privateKey: string) => { +export const getUserAppDataFromBlossom = async ( + url: string, + privateKey: string +) => { // Initialize errorCode to track HTTP error codes let errorCode = 0 @@ -799,133 +490,6 @@ const getUserAppDataFromBlossom = async (url: string, privateKey: string) => { return parsedContent } -/** - * Function to subscribe to sigits notifications for a specified public key. - * @param pubkey - The public key to subscribe to. - * @returns A promise that resolves when the subscription is successful. - */ -export const subscribeForSigits = async ( - pubkey: string, - getNDKRelayList: (pubkey: Hexpubkey) => Promise -) => { - const ndkRelayList = await getNDKRelayList(pubkey) - - // Ensure relay list is not empty - if (ndkRelayList.readRelayUrls.length === 0) return - - // Define the filter for the subscription - const filter: Filter = { - kinds: [1059], - '#p': [pubkey] - } - - // Process the received event synchronously - const events = await relayController.fetchEvents(filter, [ - ...ndkRelayList.readRelayUrls - ]) - for (const e of events) { - await processReceivedEvent(e) - } - - // Async processing of the events has a race condition - // relayController.subscribeForEvents(filter, relaySet.read, (event) => { - // processReceivedEvent(event) - // }) -} - -const processReceivedEvent = async (event: Event, difficulty: number = 5) => { - const processedEvents = store.getState().userAppData?.processedGiftWraps - - // Abort processing if userAppData is undefined - if (!processedEvents) return - - if (processedEvents.includes(event.id)) return - - store.dispatch(updateProcessedGiftWraps([...processedEvents, event.id])) - - // validate PoW - // Count the number of leading zero bits in the hash - const leadingZeroes = countLeadingZeroes(event.id) - if (leadingZeroes < difficulty) return - - // decrypt the content of gift wrap event - const nostrController = NostrController.getInstance() - const decrypted = await nostrController.nip44Decrypt( - event.pubkey, - event.content - ) - - const internalUnsignedEvent = await parseJson(decrypted).catch( - (err) => { - console.log( - 'An error occurred in parsing the internal unsigned event', - err - ) - return null - } - ) - - if (!internalUnsignedEvent || internalUnsignedEvent.kind !== 938) return - - const meta = await parseJson(internalUnsignedEvent.content).catch( - (err) => { - console.log( - 'An error occurred in parsing the internal unsigned event', - err - ) - return null - } - ) - - if (!meta) return - - await updateUsersAppData(meta) -} - -/** - * Function to send a notification to a specified receiver. - * @param receiver - The recipient's public key. - * @param meta - Metadata associated with the notification. - */ -export const sendNotification = async ( - receiver: string, - meta: Meta, - getNDKRelayList: (pubkey: Hexpubkey) => Promise -) => { - // Retrieve the user's public key from the state - const usersPubkey = store.getState().auth.usersPubkey! - - // Create an unsigned event object with the provided metadata - const unsignedEvent: UnsignedEvent = { - kind: 938, - pubkey: usersPubkey, - content: JSON.stringify(meta), - tags: [], - created_at: unixNow() - } - - // Wrap the unsigned event with the receiver's information - const wrappedEvent = createWrap(unsignedEvent, receiver) - - const ndkRelayList = await getNDKRelayList(receiver) - - // Ensure relay list is not empty - if (ndkRelayList.readRelayUrls.length === 0) return - - // Publish the notification event to the recipient's read relays - await Promise.race([ - relayController.publish(wrappedEvent, [...ndkRelayList.readRelayUrls]), - timeout(40 * 1000) - ]).catch((err) => { - // Log an error if publishing the notification event fails - console.log( - `An error occurred while publishing notification event for ${hexToNpub(receiver)}`, - err - ) - throw err - }) -} - /** * Show user's name, first available in order: display_name, name, or npub as fallback * @param npub User identifier, it can be either pubkey or npub1 (we only show npub)