chore: removed relay controller in favor of NDKContext
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 47s

This commit is contained in:
daniyal 2024-12-22 23:38:27 +05:00
parent 0ea6ba0033
commit e8a53bc73e
11 changed files with 471 additions and 773 deletions

View File

@ -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)
}

View File

@ -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<string, Promise<Relay | null>>() // Track pending connections
public connectedRelays = new Map<string, Relay>()
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<Relay | null> => {
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<Event[]> => {
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<string>() // To keep track of event IDs and avoid duplicates
// Create a promise for each relay subscription
const subPromises = relays.map((relay) => {
return new Promise<void>((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<Event | null> => {
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<string[]> => {
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()

View File

@ -1,2 +1 @@
export * from './NostrController'
export * from './RelayController'

View File

@ -3,4 +3,5 @@ export * from './useAuth'
export * from './useDidMount'
export * from './useDvm'
export * from './useLogout'
export * from './useNDK'
export * from './useNDKContext'

415
src/hooks/useNDK.ts Normal file
View File

@ -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<UserAppData | null> => {
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<Event>(
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<UnsignedEvent>(
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<Meta>(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
}
}

View File

@ -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))

View File

@ -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<File>()
@ -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)
}
}

View File

@ -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))

View File

@ -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(() => {

View File

@ -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)

View File

@ -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<string | null> => {
export const getDTagForUserAppData = async (): Promise<string | null> => {
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<NDKRelayList>
): Promise<UserAppData | null> => {
// 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<Event>(
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<NDKRelayList>
) => {
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<UnsignedEvent>(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<Meta>(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<NDKRelayList>
) => {
// 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)