From 8a9910db87dcd27488ef1588a18e4480b16adde8 Mon Sep 17 00:00:00 2001 From: Stixx Date: Mon, 2 Dec 2024 11:51:02 +0100 Subject: [PATCH 01/45] fix: include purplepage and userkindpages relays when searching for user in create page --- package-lock.json | 8 ++++---- package.json | 2 +- src/pages/create/index.tsx | 13 +++++++++++-- src/utils/relays.ts | 4 ++-- 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index a03e759..0b038f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "jszip": "3.10.1", "lodash": "4.17.21", "mui-file-input": "4.0.4", - "nostr-login": "^1.6.6", + "nostr-login": "file:../../nostrdev-com/nostr-login/packages/auth/dist/nostr-login-1.6.12.tgz", "nostr-tools": "2.7.0", "pdf-lib": "^1.17.1", "pdfjs-dist": "^4.4.168", @@ -6415,9 +6415,9 @@ } }, "node_modules/nostr-login": { - "version": "1.6.6", - "resolved": "https://registry.npmjs.org/nostr-login/-/nostr-login-1.6.6.tgz", - "integrity": "sha512-XOpB9nG3Qgt7iea7gA1zn4TaTfUKCKGdCHKwErqLPtMk/q1Rhkzj5cq/66iU0WqC6mSiwENfTy1p4qaM7HzMtg==", + "version": "1.6.12", + "resolved": "file:../../nostrdev-com/nostr-login/packages/auth/dist/nostr-login-1.6.12.tgz", + "integrity": "sha512-7qqhWSrA3Hr/An2+s7JIr1HhIpOsdwfNRgKBctPLrfjIQbiMYQd8/S25Pvv20s09yA/tS8zPgWYUUdeKoPDDNg==", "license": "MIT", "dependencies": { "@nostr-dev-kit/ndk": "^2.3.1", diff --git a/package.json b/package.json index ab49da0..2357f80 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "jszip": "3.10.1", "lodash": "4.17.21", "mui-file-input": "4.0.4", - "nostr-login": "^1.6.6", + "nostr-login": "file:../../nostrdev-com/nostr-login/packages/auth/dist/nostr-login-1.6.12.tgz", "nostr-tools": "2.7.0", "pdf-lib": "^1.17.1", "pdfjs-dist": "^4.4.168", diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index a65a559..0ad59ea 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -52,7 +52,8 @@ import { updateUsersAppData, uploadToFileStorage, DEFAULT_TOOLBOX, - settleAllFullfilfedPromises + settleAllFullfilfedPromises, + DEFAULT_LOOK_UP_RELAY_LIST } from '../../utils' import { Container } from '../../components/Container' import fileListStyles from '../../components/FileList/style.module.scss' @@ -159,6 +160,14 @@ export const CreatePage = () => { 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 @@ -167,7 +176,7 @@ export const CreatePage = () => { kinds: [0], search: searchTerm }, - [...relaySet.write] + uniqueReadRelaySet ) .then((events) => { console.log('events', events) diff --git a/src/utils/relays.ts b/src/utils/relays.ts index bfef7aa..7a0ad56 100644 --- a/src/utils/relays.ts +++ b/src/utils/relays.ts @@ -80,8 +80,8 @@ const getUserRelaySet = (tags: string[][]): RelaySet => { } const getDefaultRelaySet = (): RelaySet => ({ - read: [SIGIT_RELAY], - write: [SIGIT_RELAY] + read: DEFAULT_LOOK_UP_RELAY_LIST, + write: DEFAULT_LOOK_UP_RELAY_LIST }) const getDefaultRelayMap = (): RelayMap => ({ From 3c061d5920e2d518b6a837a61e151cc1586b88b7 Mon Sep 17 00:00:00 2001 From: daniyal Date: Fri, 6 Dec 2024 14:16:46 +0500 Subject: [PATCH 02/45] feat: added ndkContext and used it in relays page --- package-lock.json | 102 +++++++++--- package.json | 4 +- src/contexts/NDKContext.tsx | 243 ++++++++++++++++++++++++++++ src/hooks/index.ts | 2 + src/hooks/useDvm.ts | 98 +++++++++++ src/hooks/useNDKContext.ts | 13 ++ src/main.tsx | 5 +- src/pages/settings/relays/index.tsx | 138 +++++++++++++--- src/types/relay.ts | 6 + src/utils/nostr.ts | 22 +++ src/utils/relays.ts | 21 +-- 11 files changed, 590 insertions(+), 64 deletions(-) create mode 100644 src/contexts/NDKContext.tsx create mode 100644 src/hooks/useDvm.ts create mode 100644 src/hooks/useNDKContext.ts diff --git a/package-lock.json b/package-lock.json index a03e759..21aadbc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,12 +20,14 @@ "@mui/lab": "5.0.0-alpha.166", "@mui/material": "5.15.11", "@noble/hashes": "^1.4.0", - "@nostr-dev-kit/ndk": "2.5.0", + "@nostr-dev-kit/ndk": "2.10.0", + "@nostr-dev-kit/ndk-cache-dexie": "2.5.1", "@pdf-lib/fontkit": "^1.1.1", "@reduxjs/toolkit": "2.2.1", "axios": "^1.7.4", "crypto-hash": "3.0.0", "crypto-js": "^4.2.0", + "dexie": "4.0.8", "dnd-core": "16.0.1", "file-saver": "2.0.5", "idb": "8.0.0", @@ -1710,65 +1712,79 @@ } }, "node_modules/@nostr-dev-kit/ndk": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.5.0.tgz", - "integrity": "sha512-A2nRgjjLScDhGZGPWx8xUIJM66dJWScdWQoCn/tI1Gtwpple+C2Jp7C9t3mb0oF3bwd2nsV6qwS//wdrH8QvYQ==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.10.0.tgz", + "integrity": "sha512-TqCAAo6ylORraAXrzRkCGFN2xTMiFbdER8Y8CtUT0HwOpFG/Wn+PBNeDeDmqkl/6LaPdeyXmVwCWj2KcUjIwYA==", "dependencies": { + "@noble/curves": "^1.4.0", "@noble/hashes": "^1.3.1", "@noble/secp256k1": "^2.0.0", "@scure/base": "^1.1.1", "debug": "^4.3.4", "light-bolt11-decoder": "^3.0.0", "node-fetch": "^3.3.1", - "nostr-tools": "^1.15.0", + "nostr-tools": "^2.7.1", "tseep": "^1.1.1", "typescript-lru-cache": "^2.0.0", "utf8-buffer": "^1.0.0", "websocket-polyfill": "^0.0.3" + }, + "engines": { + "node": ">=16" } }, - "node_modules/@nostr-dev-kit/ndk/node_modules/@noble/ciphers": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.2.0.tgz", - "integrity": "sha512-6YBxJDAapHSdd3bLDv6x2wRPwq4QFMUaB3HvljNBUTThDd12eSm7/3F+2lnfzx2jvM+S6Nsy0jEt9QbPqSwqRw==", - "funding": { - "url": "https://paulmillr.com/funding/" + "node_modules/@nostr-dev-kit/ndk-cache-dexie": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk-cache-dexie/-/ndk-cache-dexie-2.5.1.tgz", + "integrity": "sha512-tUwEy68bd9GL5JVuZIjcpdwuDEBnaXen3WJ64/GRDtbyE1RB01Y6hHC7IQC9bcQ6SC7XBGyPd+2nuTyR7+Mffg==", + "dependencies": { + "@nostr-dev-kit/ndk": "2.10.0", + "debug": "^4.3.4", + "dexie": "^4.0.2", + "nostr-tools": "^2.4.0", + "typescript-lru-cache": "^2.0.0" } }, "node_modules/@nostr-dev-kit/ndk/node_modules/@noble/curves": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz", - "integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.7.0.tgz", + "integrity": "sha512-UTMhXK9SeDhFJVrHeUJ5uZlI6ajXg10O6Ddocf9S6GjbSBVZsJo88HzKwXznNfGpMTRDyJkqMjNDPYgf0qFWnw==", "dependencies": { - "@noble/hashes": "1.3.1" + "@noble/hashes": "1.6.0" + }, + "engines": { + "node": "^14.21.3 || >=16" }, "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/@nostr-dev-kit/ndk/node_modules/@noble/hashes": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz", - "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.6.0.tgz", + "integrity": "sha512-YUULf0Uk4/mAA89w+k3+yUYh6NrEvxZa5T6SY3wlMvE2chHkxFUUIDI8/XW1QSC357iA5pSnqt7XEhvFOqmDyQ==", "engines": { - "node": ">= 16" + "node": "^14.21.3 || >=16" }, "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/@nostr-dev-kit/ndk/node_modules/nostr-tools": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-1.17.0.tgz", - "integrity": "sha512-LZmR8GEWKZeElbFV5Xte75dOeE9EFUW/QLI1Ncn3JKn0kFddDKEfBbFN8Mu4TMs+L4HR/WTPha2l+PPuRnJcMw==", + "version": "2.10.4", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.10.4.tgz", + "integrity": "sha512-biU7sk+jxHgVASfobg2T5ttxOGGSt69wEVBC51sHHOEaKAAdzHBLV/I2l9Rf61UzClhliZwNouYhqIso4a3HYg==", "dependencies": { - "@noble/ciphers": "0.2.0", - "@noble/curves": "1.1.0", + "@noble/ciphers": "^0.5.1", + "@noble/curves": "1.2.0", "@noble/hashes": "1.3.1", "@scure/base": "1.1.1", "@scure/bip32": "1.3.1", "@scure/bip39": "1.2.1" }, + "optionalDependencies": { + "nostr-wasm": "0.1.0" + }, "peerDependencies": { "typescript": ">=5.0.0" }, @@ -1778,6 +1794,39 @@ } } }, + "node_modules/@nostr-dev-kit/ndk/node_modules/nostr-tools/node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nostr-dev-kit/ndk/node_modules/nostr-tools/node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nostr-dev-kit/ndk/node_modules/nostr-tools/node_modules/@noble/hashes": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz", + "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@pdf-lib/fontkit": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@pdf-lib/fontkit/-/fontkit-1.1.1.tgz", @@ -3850,6 +3899,11 @@ "node": ">=8" } }, + "node_modules/dexie": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.0.8.tgz", + "integrity": "sha512-1G6cJevS17KMDK847V3OHvK2zei899GwpDiqfEXHP1ASvme6eWJmAp9AU4s1son2TeGkWmC0g3y8ezOBPnalgQ==" + }, "node_modules/dezalgo": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", diff --git a/package.json b/package.json index ab49da0..983145b 100644 --- a/package.json +++ b/package.json @@ -30,12 +30,14 @@ "@mui/lab": "5.0.0-alpha.166", "@mui/material": "5.15.11", "@noble/hashes": "^1.4.0", - "@nostr-dev-kit/ndk": "2.5.0", + "@nostr-dev-kit/ndk": "2.10.0", + "@nostr-dev-kit/ndk-cache-dexie": "2.5.1", "@pdf-lib/fontkit": "^1.1.1", "@reduxjs/toolkit": "2.2.1", "axios": "^1.7.4", "crypto-hash": "3.0.0", "crypto-js": "^4.2.0", + "dexie": "4.0.8", "dnd-core": "16.0.1", "file-saver": "2.0.5", "idb": "8.0.0", diff --git a/src/contexts/NDKContext.tsx b/src/contexts/NDKContext.tsx new file mode 100644 index 0000000..edbb28a --- /dev/null +++ b/src/contexts/NDKContext.tsx @@ -0,0 +1,243 @@ +import NDK, { + getRelayListForUser, + NDKEvent, + NDKFilter, + NDKRelaySet, + NDKSubscriptionCacheUsage, + NDKUser, + NDKUserProfile +} from '@nostr-dev-kit/ndk' + +import NDKCacheAdapterDexie from '@nostr-dev-kit/ndk-cache-dexie' + +import { Dexie } from 'dexie' +import { createContext, ReactNode, useEffect, useMemo } from 'react' +import { toast } from 'react-toastify' +import { UserRelaysType } from '../types' +import { + DEFAULT_LOOK_UP_RELAY_LIST, + hexToNpub, + orderEventsChronologically, + timeout +} from '../utils' + +export interface NDKContextType { + ndk: NDK + fetchEvents: (filter: NDKFilter) => Promise + fetchEvent: (filter: NDKFilter) => Promise + fetchEventsFromUserRelays: ( + filter: NDKFilter | NDKFilter[], + hexKey: string, + userRelaysType: UserRelaysType + ) => Promise + fetchEventFromUserRelays: ( + filter: NDKFilter | NDKFilter[], + hexKey: string, + userRelaysType: UserRelaysType + ) => Promise + findMetadata: (pubkey: string) => Promise + publish: (event: NDKEvent, explicitRelayUrls?: string[]) => Promise +} + +// Create the context with an initial value of `null` +export const NDKContext = createContext(null) + +// Create a provider component to wrap around parts of your app +export const NDKContextProvider = ({ children }: { children: ReactNode }) => { + useEffect(() => { + window.onunhandledrejection = async (event: PromiseRejectionEvent) => { + event.preventDefault() + if (event.reason?.name === Dexie.errnames.DatabaseClosed) { + console.log( + 'Could not open Dexie DB, probably version change. Deleting old DB and reloading...' + ) + await Dexie.delete('degmod-db') + // Must reload to open a brand new DB + window.location.reload() + } + } + }, []) + + const ndk = useMemo(() => { + localStorage.setItem('debug', '*') + const dexieAdapter = new NDKCacheAdapterDexie({ dbName: 'degmod-db' }) + dexieAdapter.locking = true + const ndk = new NDK({ + enableOutboxModel: true, + autoConnectUserRelays: true, + autoFetchUserMutelist: true, + explicitRelayUrls: [...DEFAULT_LOOK_UP_RELAY_LIST], + cacheAdapter: dexieAdapter + }) + ndk.connect() + + return ndk + }, []) + + /** + * Asynchronously retrieves multiple event based on a provided filter. + * + * @param filter - The filter criteria to find the event. + * @returns Returns a promise that resolves to the found event or null if not found. + */ + const fetchEvents = async (filter: NDKFilter): Promise => { + return ndk + .fetchEvents(filter, { + closeOnEose: true, + cacheUsage: NDKSubscriptionCacheUsage.PARALLEL + }) + .then((ndkEventSet) => { + const ndkEvents = Array.from(ndkEventSet) + return orderEventsChronologically(ndkEvents) + }) + .catch((err) => { + // Log the error and show a notification if fetching fails + console.error('An error occurred in fetching events', err) + toast.error('An error occurred in fetching events') // Show error notification + return [] // Return an empty array in case of an error + }) + } + + /** + * Asynchronously retrieves an event based on a provided filter. + * + * @param filter - The filter criteria to find the event. + * @returns Returns a promise that resolves to the found event or null if not found. + */ + const fetchEvent = async (filter: NDKFilter) => { + const events = await fetchEvents(filter) + if (events.length === 0) return null + return events[0] + } + + /** + * Asynchronously retrieves multiple events from the user's relays based on a specified filter. + * The function first retrieves the user's relays, and then fetches the events using the provided filter. + * + * @param filter - The event filter to use when fetching the event (e.g., kinds, authors). + * @param hexKey - The hexadecimal representation of the user's public key. + * @param userRelaysType - The type of relays to search (e.g., write, read). + * @returns A promise that resolves with an array of events. + */ + const fetchEventsFromUserRelays = async ( + filter: NDKFilter | NDKFilter[], + hexKey: string, + userRelaysType: UserRelaysType + ): Promise => { + // Find the user's relays (10s timeout). + const relayUrls = await Promise.race([ + getRelayListForUser(hexKey, ndk), + timeout(3000) + ]) + .then((ndkRelayList) => { + if (ndkRelayList) return ndkRelayList[userRelaysType] + return [] // Return an empty array if ndkRelayList is undefined + }) + .catch((err) => { + console.error( + `An error occurred in fetching user's (${hexKey}) ${userRelaysType}`, + err + ) + return [] as string[] + }) + + return ndk + .fetchEvents( + filter, + { closeOnEose: true, cacheUsage: NDKSubscriptionCacheUsage.PARALLEL }, + relayUrls.length + ? NDKRelaySet.fromRelayUrls(relayUrls, ndk, true) + : undefined + ) + .then((ndkEventSet) => { + const ndkEvents = Array.from(ndkEventSet) + return orderEventsChronologically(ndkEvents) + }) + .catch((err) => { + // Log the error and show a notification if fetching fails + console.error('An error occurred in fetching events', err) + toast.error('An error occurred in fetching events') // Show error notification + return [] // Return an empty array in case of an error + }) + } + + /** + * Fetches an event from the user's relays based on a specified filter. + * The function first retrieves the user's relays, and then fetches the event using the provided filter. + * + * @param filter - The event filter to use when fetching the event (e.g., kinds, authors). + * @param hexKey - The hexadecimal representation of the user's public key. + * @param userRelaysType - The type of relays to search (e.g., write, read). + * @returns A promise that resolves to the fetched event or null if the operation fails. + */ + const fetchEventFromUserRelays = async ( + filter: NDKFilter | NDKFilter[], + hexKey: string, + userRelaysType: UserRelaysType + ) => { + const events = await fetchEventsFromUserRelays( + filter, + hexKey, + userRelaysType + ) + if (events.length === 0) return null + return events[0] + } + + /** + * Finds metadata for a given pubkey. + * + * @param hexKey - The pubkey to search for metadata. + * @returns A promise that resolves to the metadata event. + */ + const findMetadata = async ( + pubkey: string + ): Promise => { + const npub = hexToNpub(pubkey) + + const user = new NDKUser({ npub }) + user.ndk = ndk + + return await user.fetchProfile() + } + + const publish = async ( + event: NDKEvent, + explicitRelayUrls?: string[] + ): Promise => { + if (!event.sig) throw new Error('Before publishing first sign the event!') + + let ndkRelaySet: NDKRelaySet | undefined + + if (explicitRelayUrls && explicitRelayUrls.length > 0) { + ndkRelaySet = NDKRelaySet.fromRelayUrls(explicitRelayUrls, ndk) + } + + return event + .publish(ndkRelaySet, 10000) + .then((res) => { + const relaysPublishedOn = Array.from(res) + return relaysPublishedOn.map((relay) => relay.url) + }) + .catch((err) => { + console.error(`An error occurred in publishing event`, err) + return [] + }) + } + + return ( + + {children} + + ) +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index e7ec305..91dc278 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,2 +1,4 @@ export * from './store' export * from './useDidMount' +export * from './useDvm' +export * from './useNDKContext' diff --git a/src/hooks/useDvm.ts b/src/hooks/useDvm.ts new file mode 100644 index 0000000..089d16d --- /dev/null +++ b/src/hooks/useDvm.ts @@ -0,0 +1,98 @@ +import { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk' +import { EventTemplate } from 'nostr-tools' +import { NostrController } from '../controllers' +import { setRelayInfoAction } from '../store/actions' +import { RelayInfoObject } from '../types' +import { compareObjects, unixNow } from '../utils' +import { useAppDispatch, useAppSelector } from './store' +import { useNDKContext } from './useNDKContext' + +export const useDvm = () => { + const dvmRelays = [ + 'wss://relay.damus.io', + 'wss://relay.primal.net', + 'wss://relayable.org' + ] + + const relayInfo = useAppSelector((state) => state.relays.info) + + const { ndk, publish } = useNDKContext() + const dispatch = useAppDispatch() + + /** + * Sets information about relays into relays.info app state. + * @param relayURIs - relay URIs to get information about + */ + const getRelayInfo = async (relayURIs: string[]) => { + // initialize job request + const jobEventTemplate: EventTemplate = { + content: '', + created_at: unixNow(), + kind: 68001, + tags: [ + ['i', `${JSON.stringify(relayURIs)}`], + ['j', 'relay-info'] + ] + } + + const nostrController = NostrController.getInstance() + + // sign job request event + const jobSignedEvent = await nostrController.signEvent(jobEventTemplate) + + // publish job request + const ndkEvent = new NDKEvent(ndk, jobSignedEvent) + await publish(ndkEvent, dvmRelays) + + const subscribeWithTimeout = ( + subscription: NDKSubscription, + timeoutMs: number + ): Promise => { + return new Promise((resolve, reject) => { + const eventHandler = (event: NDKEvent) => { + subscription.stop() + resolve(event.content) + } + + subscription.on('event', eventHandler) + + // Set up a timeout to stop the subscription after a specified time + const timeout = setTimeout(() => { + subscription.stop() // Stop the subscription + reject(new Error('Subscription timed out')) // Reject the promise with a timeout error + }, timeoutMs) + + // Handle subscription close event + subscription.on('close', () => clearTimeout(timeout)) + }) + } + + // filter for getting DVM job's result + const sub = ndk.subscribe({ + kinds: [68002 as number], + '#e': [jobSignedEvent.id], + '#p': [jobSignedEvent.pubkey] + }) + + // asynchronously get relay info from dvm job with 20 seconds timeout + const dvmJobResult = await subscribeWithTimeout(sub, 20000) + + if (!dvmJobResult) { + return Promise.reject(`Relay(s) information wasn't received`) + } + + let newRelaysInfo: RelayInfoObject + + try { + newRelaysInfo = JSON.parse(dvmJobResult) + } catch (error) { + return Promise.reject(`Invalid relay(s) information.`) + } + + if (newRelaysInfo && !compareObjects(relayInfo, newRelaysInfo)) { + dispatch(setRelayInfoAction(newRelaysInfo)) + } + } + + return { getRelayInfo } +} diff --git a/src/hooks/useNDKContext.ts b/src/hooks/useNDKContext.ts new file mode 100644 index 0000000..9d502fc --- /dev/null +++ b/src/hooks/useNDKContext.ts @@ -0,0 +1,13 @@ +import { NDKContext, NDKContextType } from '../contexts/NDKContext' +import { useContext } from 'react' + +export const useNDKContext = () => { + const ndkContext = useContext(NDKContext) + + if (!ndkContext) + throw new Error( + 'NDKContext should not be used in out component tree hierarchy' + ) + + return { ...ndkContext } as NDKContextType +} diff --git a/src/main.tsx b/src/main.tsx index a8b1898..05ea4ed 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -11,6 +11,7 @@ import './index.css' import store from './store/store.ts' import { theme } from './theme' import { saveState } from './utils' +import { NDKContextProvider } from './contexts/NDKContext' store.subscribe( _.throttle(() => { @@ -28,7 +29,9 @@ ReactDOM.createRoot(document.getElementById('root')!).render( - + + + diff --git a/src/pages/settings/relays/index.tsx b/src/pages/settings/relays/index.tsx index a1f5223..ba776fb 100644 --- a/src/pages/settings/relays/index.tsx +++ b/src/pages/settings/relays/index.tsx @@ -13,26 +13,45 @@ import Switch from '@mui/material/Switch' import { useEffect, useState } from 'react' import { toast } from 'react-toastify' import { Container } from '../../../components/Container' -import { relayController } from '../../../controllers' -import { useAppDispatch, useAppSelector, useDidMount } from '../../../hooks' +import { + useAppDispatch, + useAppSelector, + useDidMount, + useDvm, + useNDKContext +} from '../../../hooks' import { setRelayMapAction } from '../../../store/actions' -import { RelayConnectionState, RelayFee, RelayInfo } from '../../../types' +import { + RelayConnectionState, + RelayFee, + RelayInfo, + RelayMap +} from '../../../types' import { capitalizeFirstLetter, compareObjects, - getRelayInfo, - getRelayMap, hexToNpub, + normalizeWebSocketURL, publishRelayMap, - shorten + shorten, + timeout } from '../../../utils' import styles from './style.module.scss' import { Footer } from '../../../components/Footer/Footer' +import { + getRelayListForUser, + NDKRelayList, + NDKRelayStatus +} from '@nostr-dev-kit/ndk' export const RelaysPage = () => { + const dispatch = useAppDispatch() + const { ndk, publish } = useNDKContext() + const { getRelayInfo } = useDvm() + const usersPubkey = useAppSelector((state) => state.auth?.usersPubkey) - const dispatch = useAppDispatch() + const [ndkRelayList, setNDKRelayList] = useState(null) const [newRelayURI, setNewRelayURI] = useState() const [newRelayURIerror, setNewRelayURIerror] = useState() @@ -42,22 +61,74 @@ export const RelaysPage = () => { const webSocketPrefix = 'wss://' - useDidMount(() => { + // fetch relay list from relays + useEffect(() => { if (usersPubkey) { - getRelayMap(usersPubkey).then((newRelayMap) => { - if (!compareObjects(relayMap, newRelayMap.map)) { - dispatch(setRelayMapAction(newRelayMap.map)) + Promise.race([getRelayListForUser(usersPubkey, ndk), timeout(10000)]) + .then((res) => { + setNDKRelayList(res) + }) + .catch((err) => { + toast.error( + `An error occurred in fetching user relay list: ${ + err.message || err + }` + ) + setNDKRelayList(new NDKRelayList(ndk)) + }) + } + }, [usersPubkey, ndk]) + + // construct the RelayMap from newly received NDKRelayList event + // and compare it with existing relay map in redux store + // if there are any differences then update the redux store with + // new relay map + useEffect(() => { + if (ndkRelayList) { + const newRelayMap: RelayMap = {} + + ndkRelayList.readRelayUrls.forEach((relayUrl) => { + const normalizedUrl = normalizeWebSocketURL(relayUrl) + + newRelayMap[normalizedUrl] = { + read: true, + write: false } }) + + ndkRelayList.writeRelayUrls.forEach((relayUrl) => { + const normalizedUrl = normalizeWebSocketURL(relayUrl) + + const existing = newRelayMap[normalizedUrl] + if (existing) { + existing.write = true + } else { + newRelayMap[normalizedUrl] = { + read: false, + write: true + } + } + }) + + if (!compareObjects(relayMap, newRelayMap)) { + dispatch(setRelayMapAction(newRelayMap)) + } } - }) + + // we want to run this effect only when ndkRelayList is changed + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ndkRelayList]) useEffect(() => { + if (!relayMap) return + // Display notification if an empty relay map has been received - if (relayMap && Object.keys(relayMap).length === 0) { + if (Object.keys(relayMap).length === 0) { relayRequirementWarning() + } else { + getRelayInfo(Object.keys(relayMap)) } - }, [relayMap]) + }, [relayMap, getRelayInfo]) const relayRequirementWarning = () => toast.warning('At least one write relay is needed for SIGit to work.') @@ -85,7 +156,8 @@ export const RelaysPage = () => { const relayMapPublishingRes = await publishRelayMap( relayMapCopy, usersPubkey, - [relay] + ndk, + publish ).catch((err) => handlePublishRelayMapError(err)) if (relayMapPublishingRes) { @@ -132,7 +204,9 @@ export const RelaysPage = () => { // Publish updated relay map const relayMapPublishingRes = await publishRelayMap( relayMapCopy, - usersPubkey + usersPubkey, + ndk, + publish ).catch((err) => handlePublishRelayMapError(err)) if (relayMapPublishingRes) { @@ -161,9 +235,10 @@ export const RelaysPage = () => { ) } } else if (relayURI && usersPubkey) { - const relay = await relayController.connectRelay(relayURI) + const ndkRelay = ndk.pool.getRelay(relayURI) + await ndkRelay.connect(5000) - if (relay && relay.connected) { + if (ndkRelay.status >= NDKRelayStatus.CONNECTED) { const relayMapCopy = JSON.parse(JSON.stringify(relayMap)) relayMapCopy[relayURI] = { write: true, read: true } @@ -171,7 +246,9 @@ export const RelaysPage = () => { // Publish updated relay map const relayMapPublishingRes = await publishRelayMap( relayMapCopy, - usersPubkey + usersPubkey, + ndk, + publish ).catch((err) => handlePublishRelayMapError(err)) if (relayMapPublishingRes) { @@ -256,19 +333,36 @@ const RelayItem = ({ handleLeaveRelay, handleRelayWriteChange }: RelayItemProp) => { + const { ndk } = useNDKContext() + const [relayConnectionStatus, setRelayConnectionStatus] = useState() const [displayRelayInfo, setDisplayRelayInfo] = useState(false) useDidMount(() => { - relayController.connectRelay(relayURI).then((relay) => { - if (relay && relay.connected) { + const ndkPool = ndk.pool + + ndkPool.on('relay:connect', (relay) => { + if (relay.url === relayURI) { setRelayConnectionStatus(RelayConnectionState.Connected) - } else { + } + }) + + ndkPool.on('relay:disconnect', (relay) => { + if (relay.url === relayURI) { setRelayConnectionStatus(RelayConnectionState.NotConnected) } }) + + const relay = ndkPool.getRelay(relayURI) + if (relay) { + setRelayConnectionStatus( + relay.status >= NDKRelayStatus.CONNECTED + ? RelayConnectionState.Connected + : RelayConnectionState.NotConnected + ) + } }) return ( diff --git a/src/types/relay.ts b/src/types/relay.ts index dd41095..401e5ee 100644 --- a/src/types/relay.ts +++ b/src/types/relay.ts @@ -1,3 +1,9 @@ +export enum UserRelaysType { + Read = 'readRelayUrls', + Write = 'writeRelayUrls', + Both = 'bothRelayUrls' +} + export interface RelaySet { read: string[] write: string[] diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index ec8c97e..e7dbcbe 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -35,6 +35,7 @@ import { parseJson, removeLeadingSlash } from './string' import { timeout } from './utils' import { getHash } from './hash' import { SIGIT_BLOSSOM } from './const.ts' +import { NDKEvent } from '@nostr-dev-kit/ndk' /** * Generates a `d` tag for userAppData @@ -989,3 +990,24 @@ export const getProfileUsername = ( truncate(profile?.display_name || profile?.name || hexToNpub(npub), { length: 16 }) + +/** + * Orders an array of NDKEvent objects chronologically based on their `created_at` property. + * + * @param events - The array of NDKEvent objects to be sorted. + * @param reverse - Optional flag to reverse the sorting order. + * If true, sorts in ascending order (oldest first), otherwise sorts in descending order (newest first). + * + * @returns The sorted array of events. + */ +export function orderEventsChronologically( + events: NDKEvent[], + reverse: boolean = false +): NDKEvent[] { + events.sort((e1: NDKEvent, e2: NDKEvent) => { + if (reverse) return e1.created_at! - e2.created_at! + else return e2.created_at! - e1.created_at! + }) + + return events +} diff --git a/src/utils/relays.ts b/src/utils/relays.ts index bfef7aa..bcbbad9 100644 --- a/src/utils/relays.ts +++ b/src/utils/relays.ts @@ -10,6 +10,7 @@ import { ONE_WEEK_IN_MS, SIGIT_RELAY } from './const' +import NDK, { NDKEvent } from '@nostr-dev-kit/ndk' const READ_MARKER = 'read' const WRITE_MARKER = 'write' @@ -176,7 +177,8 @@ const getRelayMap = async ( const publishRelayMap = async ( relayMap: RelayMap, npub: string, - extraRelaysToPublish?: string[] + ndk: NDK, + publish: (event: NDKEvent) => Promise ): Promise => { const timestamp = unixNow() const relayURIs = Object.keys(relayMap) @@ -205,21 +207,8 @@ const publishRelayMap = async ( const nostrController = NostrController.getInstance() const signedEvent = await nostrController.signEvent(newRelayMapEvent) - let relaysToPublish = relayURIs - - // Add extra relays if provided - if (extraRelaysToPublish) { - relaysToPublish = [...relaysToPublish, ...extraRelaysToPublish] - } - - // If relay map is empty, use most popular relay URIs - if (!relaysToPublish.length) { - relaysToPublish = DEFAULT_LOOK_UP_RELAY_LIST - } - const publishResult = await relayController.publish( - signedEvent, - relaysToPublish - ) + const ndkEvent = new NDKEvent(ndk, signedEvent) + const publishResult = await publish(ndkEvent) if (publishResult && publishResult.length) { return Promise.resolve( From 2248128001ef1de641fe4846f8968893b772f5d7 Mon Sep 17 00:00:00 2001 From: daniyal Date: Fri, 6 Dec 2024 21:12:40 +0500 Subject: [PATCH 03/45] chore(refactor): use NDKContext in profile page --- src/contexts/NDKContext.tsx | 4 +- src/pages/profile/index.tsx | 174 ++++++++-------------- src/pages/settings/profile/index.tsx | 208 ++++++++++++--------------- 3 files changed, 155 insertions(+), 231 deletions(-) diff --git a/src/contexts/NDKContext.tsx b/src/contexts/NDKContext.tsx index edbb28a..3075f5e 100644 --- a/src/contexts/NDKContext.tsx +++ b/src/contexts/NDKContext.tsx @@ -198,7 +198,9 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => { const user = new NDKUser({ npub }) user.ndk = ndk - return await user.fetchProfile() + return await user.fetchProfile({ + cacheUsage: NDKSubscriptionCacheUsage.PARALLEL + }) } const publish = async ( diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx index 7a2f720..e50bb30 100644 --- a/src/pages/profile/index.tsx +++ b/src/pages/profile/index.tsx @@ -1,48 +1,49 @@ +import { useEffect, useState } from 'react' +import { Link, useNavigate, useParams } from 'react-router-dom' +import { toast } from 'react-toastify' + import ContentCopyIcon from '@mui/icons-material/ContentCopy' import EditIcon from '@mui/icons-material/Edit' import { Box, IconButton, SxProps, Theme, Typography } from '@mui/material' -import { Event, VerifiedEvent, kinds, nip19 } from 'nostr-tools' -import { useEffect, useMemo, useState } from 'react' -import { useAppSelector } from '../../hooks/store' -import { Link, useNavigate, useParams } from 'react-router-dom' -import { toast } from 'react-toastify' + +import { nip19 } from 'nostr-tools' + +import { Container } from '../../components/Container' +import { Footer } from '../../components/Footer/Footer' import { LoadingSpinner } from '../../components/LoadingSpinner' -import { MetadataController } from '../../controllers' +import { useAppSelector } from '../../hooks/store' + import { getProfileSettingsRoute } from '../../routes' -import { NostrJoiningBlock, ProfileMetadata } from '../../types' + import { - getNostrJoiningBlockNumber, getProfileUsername, getRoboHashPicture, hexToNpub, shorten } from '../../utils' + +import { NDKEvent, NDKUserProfile, profileFromEvent } from '@nostr-dev-kit/ndk' +import { useNDKContext } from '../../hooks' import styles from './style.module.scss' -import { Container } from '../../components/Container' -import { Footer } from '../../components/Footer/Footer' export const ProfilePage = () => { const navigate = useNavigate() const { npub } = useParams() - - const metadataController = useMemo(() => MetadataController.getInstance(), []) + const { ndk, findMetadata } = useNDKContext() const [pubkey, setPubkey] = useState() - const [nostrJoiningBlock, setNostrJoiningBlock] = - useState(null) - const [profileMetadata, setProfileMetadata] = useState() + const [userProfile, setUserProfile] = useState(null) + + const userRobotImage = useAppSelector((state) => state.userRobotImage) const metadataState = useAppSelector((state) => state.metadata) const { usersPubkey } = useAppSelector((state) => state.auth) - const userRobotImage = useAppSelector((state) => state.userRobotImage) const [isUsersOwnProfile, setIsUsersOwnProfile] = useState(false) const [isLoading, setIsLoading] = useState(true) const [loadingSpinnerDesc] = useState('Fetching metadata') - const profileName = pubkey && getProfileUsername(pubkey, profileMetadata) - useEffect(() => { if (npub) { try { @@ -57,60 +58,30 @@ export const ProfilePage = () => { }, [npub, usersPubkey]) useEffect(() => { - if (pubkey) { - getNostrJoiningBlockNumber(pubkey) - .then((res) => { - setNostrJoiningBlock(res) - }) - .catch((err) => { - // todo: handle error - console.log('err :>> ', err) - }) - } - if (isUsersOwnProfile && metadataState) { - const metadataContent = metadataController.extractProfileMetadataContent( - metadataState as VerifiedEvent - ) - if (metadataContent) { - setProfileMetadata(metadataContent) - setIsLoading(false) - } + const ndkEvent = new NDKEvent(ndk, metadataState) + const profile = profileFromEvent(ndkEvent) + + setUserProfile(profile) + + setIsLoading(false) return } if (pubkey) { - const getMetadata = async (pubkey: string) => { - const handleMetadataEvent = (event: Event) => { - const metadataContent = - metadataController.extractProfileMetadataContent(event) - if (metadataContent) { - setProfileMetadata(metadataContent) - } - } - - metadataController.on(pubkey, (kind: number, event: Event) => { - if (kind === kinds.Metadata) { - handleMetadataEvent(event) - } + findMetadata(pubkey) + .then((profile) => { + setUserProfile(profile) + }) + .catch((err) => { + toast.error(err) + }) + .finally(() => { + setIsLoading(false) }) - - const metadataEvent = await metadataController - .findMetadata(pubkey) - .catch((err) => { - toast.error(err) - return null - }) - - if (metadataEvent) handleMetadataEvent(metadataEvent) - - setIsLoading(false) - } - - getMetadata(pubkey) } - }, [isUsersOwnProfile, metadataState, pubkey, metadataController]) + }, [ndk, isUsersOwnProfile, metadataState, pubkey, findMetadata]) /** * Rendering text with button which copies the provided text @@ -146,29 +117,32 @@ export const ProfilePage = () => { * * @returns robohash image url */ - const getProfileImage = (metadata: ProfileMetadata) => { - if (!metadata) return '' + const getProfileImage = (profile: NDKUserProfile | null) => { + if (!profile) return getRoboHashPicture(npub) if (!isUsersOwnProfile) { - return metadata.picture || getRoboHashPicture(npub!) + return profile.image || getRoboHashPicture(npub!) } // userRobotImage is used only when visiting own profile // while kind 0 picture is not set - return metadata.picture || userRobotImage || getRoboHashPicture(npub!) + return profile.image || userRobotImage || getRoboHashPicture(npub!) } + const profileName = + pubkey && getProfileUsername(pubkey, userProfile || undefined) + return ( <> {isLoading && } {pubkey && ( - {profileMetadata && profileMetadata.banner ? ( + {userProfile && userProfile.banner ? ( {`banner ) : ( @@ -189,24 +163,12 @@ export const ProfilePage = () => { > {profileName} - - - {nostrJoiningBlock - ? `On nostr since ${nostrJoiningBlock.block.toLocaleString()}` - : 'On nostr since: unknown'} - - + {isUsersOwnProfile && ( { display: 'flex' }} > - {profileMetadata && ( - - {profileName} - - )} + + {profileName} + {textElementWithCopyIcon( @@ -242,42 +202,34 @@ export const ProfilePage = () => { )} - {profileMetadata?.nip05 && - textElementWithCopyIcon( - profileMetadata.nip05, - undefined, - 15 - )} + {userProfile?.nip05 && + textElementWithCopyIcon(userProfile.nip05, undefined, 15)} - {profileMetadata?.lud16 && - textElementWithCopyIcon( - profileMetadata.lud16, - undefined, - 15 - )} + {userProfile?.lud16 && + textElementWithCopyIcon(userProfile.lud16, undefined, 15)} - {profileMetadata?.website && ( + {userProfile?.website && ( - {profileMetadata.website} + {userProfile.website} )} - {profileMetadata?.about && ( + {userProfile?.about && ( - {profileMetadata.about} + {userProfile.about} )} diff --git a/src/pages/settings/profile/index.tsx b/src/pages/settings/profile/index.tsx index 1a3e34f..8432406 100644 --- a/src/pages/settings/profile/index.tsx +++ b/src/pages/settings/profile/index.tsx @@ -1,4 +1,11 @@ +import React, { useEffect, useRef, useState } from 'react' +import { useParams } from 'react-router-dom' +import { toast } from 'react-toastify' + +import { SmartToy } from '@mui/icons-material' import ContentCopyIcon from '@mui/icons-material/ContentCopy' +import LaunchIcon from '@mui/icons-material/Launch' +import { LoadingButton } from '@mui/lab' import { Box, IconButton, @@ -7,59 +14,53 @@ import { ListItem, ListSubheader, TextField, - Tooltip, - Typography, - useTheme + Tooltip } from '@mui/material' -import { UnsignedEvent, nip19, kinds, VerifiedEvent, Event } from 'nostr-tools' -import React, { useEffect, useRef, useState } from 'react' -import { Link, useParams } from 'react-router-dom' -import { toast } from 'react-toastify' -import { MetadataController, NostrController } from '../../../controllers' -import { NostrJoiningBlock, ProfileMetadata } from '../../../types' -import styles from './style.module.scss' + +import { + NDKEvent, + NDKUserProfile, + profileFromEvent, + serializeProfile +} from '@nostr-dev-kit/ndk' +import { launch as launchNostrLoginDialog } from 'nostr-login' +import { kinds, nip19, UnsignedEvent } from 'nostr-tools' + +import { NostrController } from '../../../controllers' + +import { useNDKContext } from '../../../hooks' import { useAppDispatch, useAppSelector } from '../../../hooks/store' -import { LoadingButton } from '@mui/lab' -import { Dispatch } from '../../../store/store' -import { setMetadataEvent } from '../../../store/actions' -import { LoadingSpinner } from '../../../components/LoadingSpinner' -import { LoginMethod, NostrLoginAuthMethod } from '../../../store/auth/types' -import { SmartToy } from '@mui/icons-material' -import { - getNostrJoiningBlockNumber, - getRoboHashPicture, - unixNow -} from '../../../utils' +import { getRoboHashPicture, unixNow } from '../../../utils' + import { Container } from '../../../components/Container' import { Footer } from '../../../components/Footer/Footer' -import LaunchIcon from '@mui/icons-material/Launch' -import { launch as launchNostrLoginDialog } from 'nostr-login' +import { LoadingSpinner } from '../../../components/LoadingSpinner' + +import { setMetadataEvent } from '../../../store/actions' +import { LoginMethod, NostrLoginAuthMethod } from '../../../store/auth/types' +import { Dispatch } from '../../../store/store' + +import styles from './style.module.scss' export const ProfileSettingsPage = () => { - const theme = useTheme() - - const { npub } = useParams() - const dispatch: Dispatch = useAppDispatch() - const metadataController = MetadataController.getInstance() - const nostrController = NostrController.getInstance() + const { npub } = useParams() + const { ndk, findMetadata, publish } = useNDKContext() const [pubkey, setPubkey] = useState() - const [nostrJoiningBlock, setNostrJoiningBlock] = - useState(null) - const [profileMetadata, setProfileMetadata] = useState() - const [savingProfileMetadata, setSavingProfileMetadata] = useState(false) + const [userProfile, setUserProfile] = useState(null) + + const userRobotImage = useAppSelector((state) => state.userRobotImage) const metadataState = useAppSelector((state) => state.metadata) const keys = useAppSelector((state) => state.auth?.keyPair) const { usersPubkey, loginMethod, nostrLoginAuthMethod } = useAppSelector( (state) => state.auth ) - const userRobotImage = useAppSelector((state) => state.userRobotImage) + const [savingProfileMetadata, setSavingProfileMetadata] = useState(false) const [isUsersOwnProfile, setIsUsersOwnProfile] = useState(false) - const [isLoading, setIsLoading] = useState(true) const [loadingSpinnerDesc] = useState('Fetching metadata') @@ -79,63 +80,33 @@ export const ProfileSettingsPage = () => { }, [npub, usersPubkey]) useEffect(() => { - if (pubkey) { - getNostrJoiningBlockNumber(pubkey) - .then((res) => { - setNostrJoiningBlock(res) - }) - .catch((err) => { - // todo: handle error - console.log('err :>> ', err) - }) - } - if (isUsersOwnProfile && metadataState) { - const metadataContent = metadataController.extractProfileMetadataContent( - metadataState as VerifiedEvent - ) - if (metadataContent) { - setProfileMetadata(metadataContent) - setIsLoading(false) - } + const ndkEvent = new NDKEvent(ndk, metadataState) + const profile = profileFromEvent(ndkEvent) + + setUserProfile(profile) + + setIsLoading(false) return } if (pubkey) { - const getMetadata = async (pubkey: string) => { - const handleMetadataEvent = (event: Event) => { - const metadataContent = - metadataController.extractProfileMetadataContent(event) - if (metadataContent) { - setProfileMetadata(metadataContent) - } - } - - metadataController.on(pubkey, (kind: number, event: Event) => { - if (kind === kinds.Metadata) { - handleMetadataEvent(event) - } + findMetadata(pubkey) + .then((profile) => { + setUserProfile(profile) + }) + .catch((err) => { + toast.error(err) + }) + .finally(() => { + setIsLoading(false) }) - - const metadataEvent = await metadataController - .findMetadata(pubkey) - .catch((err) => { - toast.error(err) - return null - }) - - if (metadataEvent) handleMetadataEvent(metadataEvent) - - setIsLoading(false) - } - - getMetadata(pubkey) } - }, [isUsersOwnProfile, metadataState, pubkey, metadataController]) + }, [ndk, isUsersOwnProfile, metadataState, pubkey, findMetadata]) const editItem = ( - key: keyof ProfileMetadata, + key: keyof NDKUserProfile, label: string, multiline = false, rows = 1, @@ -145,7 +116,7 @@ export const ProfileSettingsPage = () => { { onChange={(event: React.ChangeEvent) => { const { value } = event.target - setProfileMetadata((prev) => ({ + setUserProfile((prev) => ({ ...prev, [key]: value })) @@ -197,32 +168,45 @@ export const ProfileSettingsPage = () => { ) const handleSaveMetadata = async () => { + if (!userProfile) return + setSavingProfileMetadata(true) - const content = JSON.stringify(profileMetadata) + const serializedProfile = serializeProfile(userProfile) - // We need to omit cachedAt and create new event - // Relay will reject if created_at is too late - const updatedMetadataState: UnsignedEvent = { - content: content, + const unsignedEvent: UnsignedEvent = { + content: serializedProfile, created_at: unixNow(), kind: kinds.Metadata, pubkey: pubkey!, - tags: metadataState?.tags || [] + tags: [] } + const nostrController = NostrController.getInstance() const signedEvent = await nostrController - .signEvent(updatedMetadataState) + .signEvent(unsignedEvent) .catch((error) => { toast.error(`Error saving profile metadata. ${error}`) + return null }) - if (signedEvent) { - if (!metadataController.validate(signedEvent)) { - toast.error(`Metadata is not valid.`) - } + if (!signedEvent) { + setSavingProfileMetadata(false) + return + } - await metadataController.publishMetadataEvent(signedEvent) + const ndkEvent = new NDKEvent(ndk, signedEvent) + const publishedOnRelays = await publish(ndkEvent) + + // Handle cases where publishing failed or succeeded + if (publishedOnRelays.length === 0) { + toast.error('Failed to publish event on any relay') + } else { + toast.success( + `Event published successfully to the following relays\n\n${publishedOnRelays.join( + '\n' + )}` + ) dispatch(setMetadataEvent(signedEvent)) } @@ -241,7 +225,7 @@ export const ProfileSettingsPage = () => { const robotAvatarLink = getRoboHashPicture(npub!, robotSet.current) - setProfileMetadata((prev) => ({ + setUserProfile((prev) => ({ ...prev, picture: robotAvatarLink })) @@ -267,14 +251,14 @@ export const ProfileSettingsPage = () => { * * @returns robohash image url */ - const getProfileImage = (metadata: ProfileMetadata) => { + const getProfileImage = (profile: NDKUserProfile) => { if (!isUsersOwnProfile) { - return metadata.picture || getRoboHashPicture(npub!) + return profile.image || getRoboHashPicture(npub!) } // userRobotImage is used only when visiting own profile // while kind 0 picture is not set - return metadata.picture || userRobotImage || getRoboHashPicture(npub!) + return profile.image || userRobotImage || getRoboHashPicture(npub!) } return ( @@ -300,7 +284,7 @@ export const ProfileSettingsPage = () => { } > - {profileMetadata && ( + {userProfile && (
{ flexDirection: 'column' }} > - {profileMetadata.banner ? ( + {userProfile.banner ? ( Banner Image ) : ( @@ -334,24 +318,9 @@ export const ProfileSettingsPage = () => { event.currentTarget.src = getRoboHashPicture(npub!) }} className={styles.img} - src={getProfileImage(profileMetadata)} + src={getProfileImage(userProfile)} alt="Profile Image" /> - - {nostrJoiningBlock && ( - - On nostr since {nostrJoiningBlock.block.toLocaleString()} - - )} {editItem('picture', 'Picture URL', undefined, undefined, { @@ -368,6 +337,7 @@ export const ProfileSettingsPage = () => { <> {usersPubkey && copyItem(nip19.npubEncode(usersPubkey), 'Public Key')} + {loginMethod === LoginMethod.privateKey && keys && keys.private && From 3d1bdece4d881f347e974506af9d01d9be01f4f7 Mon Sep 17 00:00:00 2001 From: enes Date: Fri, 6 Dec 2024 20:00:38 +0100 Subject: [PATCH 04/45] feat(meta): send notifications with blossom instead of meta.json --- src/hooks/useSigitMeta.tsx | 8 +-- src/pages/create/index.tsx | 16 +++-- src/pages/sign/index.tsx | 14 +++-- src/pages/verify/index.tsx | 13 ++++- src/types/core.ts | 9 +++ src/types/errors/MetaStorageError.ts | 26 +++++++++ src/utils/meta.ts | 87 +++++++++++++++++++++++++++- src/utils/nostr.ts | 66 ++++++++++++++++----- 8 files changed, 210 insertions(+), 29 deletions(-) create mode 100644 src/types/errors/MetaStorageError.ts diff --git a/src/hooks/useSigitMeta.tsx b/src/hooks/useSigitMeta.tsx index 85841f2..5c1159e 100644 --- a/src/hooks/useSigitMeta.tsx +++ b/src/hooks/useSigitMeta.tsx @@ -45,7 +45,7 @@ export interface FlatMeta isValid: boolean // Decryption - encryptionKey: string | null + encryptionKey: string | undefined // Parsed Document Signatures parsedSignatureEvents: { @@ -101,7 +101,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { [signer: `npub1${string}`]: SignStatus }>({}) - const [encryptionKey, setEncryptionKey] = useState(null) + const [encryptionKey, setEncryptionKey] = useState() useEffect(() => { if (!meta) return @@ -143,7 +143,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { setMarkConfig(markConfig) setZipUrl(zipUrl) - let encryptionKey: string | null = null + let encryptionKey: string | undefined if (meta.keys) { const { sender, keys } = meta.keys // Retrieve the user's public key from the state @@ -161,7 +161,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { 'An error occurred in decrypting encryption key', err ) - return null + return undefined }) encryptionKey = decrypted diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index 212f7bf..f0c1f0c 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -31,6 +31,7 @@ import { KeyboardCode, Meta, ProfileMetadata, + SigitNotification, SignedEvent, User, UserRole @@ -52,7 +53,8 @@ import { updateUsersAppData, uploadToFileStorage, DEFAULT_TOOLBOX, - settleAllFullfilfedPromises + settleAllFullfilfedPromises, + uploadMetaToFileStorage } from '../../utils' import { Container } from '../../components/Container' import fileListStyles from '../../components/FileList/style.module.scss' @@ -782,7 +784,7 @@ export const CreatePage = () => { } // Send notifications to signers and viewers - const sendNotifications = (meta: Meta) => { + const sendNotifications = (notification: SigitNotification) => { // no need to send notification to self so remove it from the list const receivers = ( signers.length > 0 @@ -790,7 +792,7 @@ export const CreatePage = () => { : viewers.map((viewer) => viewer.pubkey) ).filter((receiver) => receiver !== usersPubkey) - return receivers.map((receiver) => sendNotification(receiver, meta)) + return receivers.map((receiver) => sendNotification(receiver, notification)) } const extractNostrId = (stringifiedEvent: string): string => { @@ -865,11 +867,17 @@ export const CreatePage = () => { } setLoadingSpinnerDesc('Updating user app data') + + const metaUrl = await uploadMetaToFileStorage(meta, encryptionKey) + const event = await updateUsersAppData(meta) if (!event) return setLoadingSpinnerDesc('Sending notifications to counterparties') - const promises = sendNotifications(meta) + const promises = sendNotifications({ + metaUrl, + keys: meta.keys + }) await Promise.all(promises) .then(() => { diff --git a/src/pages/sign/index.tsx b/src/pages/sign/index.tsx index f30ecdd..346e226 100644 --- a/src/pages/sign/index.tsx +++ b/src/pages/sign/index.tsx @@ -34,7 +34,8 @@ import { updateUsersAppData, findOtherUserMarks, timeout, - processMarks + processMarks, + uploadMetaToFileStorage } from '../../utils' import { Container } from '../../components/Container' import { DisplayMeta } from './internal/displayMeta' @@ -635,7 +636,7 @@ export const SignPage = () => { } if (await isOnline()) { - await handleOnlineFlow(updatedMeta) + await handleOnlineFlow(updatedMeta, encryptionKey) } else { setMeta(updatedMeta) setIsLoading(false) @@ -741,7 +742,10 @@ export const SignPage = () => { } // Handle the online flow: update users app data and send notifications - const handleOnlineFlow = async (meta: Meta) => { + const handleOnlineFlow = async ( + meta: Meta, + encryptionKey: string | undefined + ) => { setLoadingSpinnerDesc('Updating users app data') const updatedEvent = await updateUsersAppData(meta) if (!updatedEvent) { @@ -749,6 +753,8 @@ export const SignPage = () => { return } + const metaUrl = await uploadMetaToFileStorage(meta, encryptionKey) + const userSet = new Set<`npub1${string}`>() if (submittedBy && submittedBy !== usersPubkey) { userSet.add(hexToNpub(submittedBy)) @@ -781,7 +787,7 @@ export const SignPage = () => { setLoadingSpinnerDesc('Sending notifications') const users = Array.from(userSet) const promises = users.map((user) => - sendNotification(npubToHex(user)!, meta) + sendNotification(npubToHex(user)!, { metaUrl, keys: meta.keys }) ) await Promise.all(promises) .then(() => { diff --git a/src/pages/verify/index.tsx b/src/pages/verify/index.tsx index 515a257..e870a23 100644 --- a/src/pages/verify/index.tsx +++ b/src/pages/verify/index.tsx @@ -23,7 +23,8 @@ import { getCurrentUserFiles, updateUsersAppData, npubToHex, - sendNotification + sendNotification, + uploadMetaToFileStorage } from '../../utils' import styles from './style.module.scss' import { useLocation, useParams } from 'react-router-dom' @@ -351,6 +352,11 @@ export const VerifyPage = () => { const updatedEvent = await updateUsersAppData(updatedMeta) if (!updatedEvent) return + const metaUrl = await uploadMetaToFileStorage( + updatedMeta, + encryptionKey + ) + const userSet = new Set<`npub1${string}`>() signers.forEach((signer) => { if (signer !== usersPubkey) { @@ -364,7 +370,10 @@ export const VerifyPage = () => { const users = Array.from(userSet) const promises = users.map((user) => - sendNotification(npubToHex(user)!, updatedMeta) + sendNotification(npubToHex(user)!, { + metaUrl, + keys: meta.keys! + }) ) await Promise.all(promises) diff --git a/src/types/core.ts b/src/types/core.ts index df55a07..f07dbf7 100644 --- a/src/types/core.ts +++ b/src/types/core.ts @@ -83,3 +83,12 @@ export interface UserAppData { export interface DocSignatureEvent extends Event { parsedContent?: SignedEventContent } + +export interface SigitNotification { + metaUrl: string + keys?: { sender: string; keys: { [user: `npub1${string}`]: string } } +} + +export function isSigitNotification(obj: unknown): obj is SigitNotification { + return typeof (obj as SigitNotification).metaUrl === 'string' +} diff --git a/src/types/errors/MetaStorageError.ts b/src/types/errors/MetaStorageError.ts new file mode 100644 index 0000000..a5bc2cd --- /dev/null +++ b/src/types/errors/MetaStorageError.ts @@ -0,0 +1,26 @@ +import { Jsonable } from '.' + +export enum MetaStorageErrorType { + 'ENCRYPTION_KEY_REQUIRED' = 'Encryption key is required.', + 'HASHING_FAILED' = "Can't get encrypted file hash.", + 'FETCH_FAILED' = 'Fetching meta.json requires an encryption key.', + 'HASH_VERIFICATION_FAILED' = 'Unable to verify meta.json.', + 'DECRYPTION_FAILED' = 'Error decryping meta.json.', + 'UNHANDLED_FETCH_ERROR' = 'Unable to fetch meta.json. Something went wrong.' +} + +export class MetaStorageError extends Error { + public readonly context?: Jsonable + + constructor( + message: MetaStorageErrorType, + options: { cause?: Error; context?: Jsonable } = {} + ) { + const { cause, context } = options + + super(message, { cause }) + this.name = this.constructor.name + + this.context = context + } +} diff --git a/src/utils/meta.ts b/src/utils/meta.ts index c915f66..8052abf 100644 --- a/src/utils/meta.ts +++ b/src/utils/meta.ts @@ -1,5 +1,12 @@ import { CreateSignatureEventContent, Meta } from '../types' -import { fromUnixTimestamp, parseJson } from '.' +import { + decryptArrayBuffer, + encryptArrayBuffer, + fromUnixTimestamp, + getHash, + parseJson, + uploadToFileStorage +} from '.' import { Event, verifyEvent } from 'nostr-tools' import { toast } from 'react-toastify' import { extractFileExtensions } from './file' @@ -8,6 +15,11 @@ import { MetaParseError, MetaParseErrorType } from '../types/errors/MetaParseError' +import axios from 'axios' +import { + MetaStorageError, + MetaStorageErrorType +} from '../types/errors/MetaStorageError' export enum SignStatus { Signed = 'Signed', @@ -126,3 +138,76 @@ export const extractSigitCardDisplayInfo = async (meta: Meta) => { } } } + +export const uploadMetaToFileStorage = async ( + meta: Meta, + encryptionKey: string | undefined +) => { + // Value is the stringified meta object + const value = JSON.stringify(meta) + const encoder = new TextEncoder() + + // Encode it to the arrayBuffer + const uint8Array = encoder.encode(value) + + if (!encryptionKey) { + throw new MetaStorageError(MetaStorageErrorType.ENCRYPTION_KEY_REQUIRED) + } + + // Encrypt the file contents with the same encryption key from the create signature + const encryptedArrayBuffer = await encryptArrayBuffer( + uint8Array, + encryptionKey + ) + + const hash = await getHash(encryptedArrayBuffer) + if (!hash) { + throw new MetaStorageError(MetaStorageErrorType.HASHING_FAILED) + } + + // Create the encrypted json file from array buffer and hash + const file = new File([encryptedArrayBuffer], `${hash}.json`) + const url = await uploadToFileStorage(file) + + return url +} + +export const fetchMetaFromFileStorage = async ( + url: string, + encryptionKey: string | undefined +) => { + if (!encryptionKey) { + throw new MetaStorageError(MetaStorageErrorType.ENCRYPTION_KEY_REQUIRED) + } + + const encryptedArrayBuffer = await axios.get(url, { + responseType: 'arraybuffer' + }) + + // Verify hash + const parts = url.split('/') + const urlHash = parts[parts.length - 1] + const hash = await getHash(encryptedArrayBuffer.data) + if (hash !== urlHash) { + throw new MetaStorageError(MetaStorageErrorType.HASH_VERIFICATION_FAILED) + } + + const arrayBuffer = await decryptArrayBuffer( + encryptedArrayBuffer.data, + encryptionKey + ).catch((err) => { + throw new MetaStorageError(MetaStorageErrorType.DECRYPTION_FAILED, { + cause: err + }) + }) + + if (arrayBuffer) { + // Decode meta.json and parse + const decoder = new TextDecoder() + const json = decoder.decode(arrayBuffer) + const meta = await parseJson(json) + return meta + } + + throw new MetaStorageError(MetaStorageErrorType.UNHANDLED_FETCH_ERROR) +} diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index ec8c97e..ed51fac 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -29,12 +29,20 @@ import { } from '../store/actions' import { Keys } from '../store/auth/types' import store from '../store/store' -import { Meta, ProfileMetadata, SignedEvent, UserAppData } from '../types' +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 { SIGIT_BLOSSOM } from './const.ts' +import { fetchMetaFromFileStorage } from './meta.ts' /** * Generates a `d` tag for userAppData @@ -908,17 +916,44 @@ const processReceivedEvent = async (event: Event, difficulty: number = 5) => { 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 - } - ) + const parsedContent = await parseJson( + internalUnsignedEvent.content + ).catch((err) => { + console.log('An error occurred in parsing the internal unsigned event', err) + return null + }) - if (!meta) return + 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 + } + + meta = await fetchMetaFromFileStorage(notification.metaUrl, encryptionKey) + } else { + meta = parsedContent + } await updateUsersAppData(meta) } @@ -926,9 +961,12 @@ const processReceivedEvent = async (event: Event, difficulty: number = 5) => { /** * Function to send a notification to a specified receiver. * @param receiver - The recipient's public key. - * @param meta - Metadata associated with the notification. + * @param notification - Url pointing to metadata associated with the notification on blossom and keys to decrypt. */ -export const sendNotification = async (receiver: string, meta: Meta) => { +export const sendNotification = async ( + receiver: string, + notification: SigitNotification +) => { // Retrieve the user's public key from the state const usersPubkey = store.getState().auth.usersPubkey! @@ -936,7 +974,7 @@ export const sendNotification = async (receiver: string, meta: Meta) => { const unsignedEvent: UnsignedEvent = { kind: 938, pubkey: usersPubkey, - content: JSON.stringify(meta), + content: JSON.stringify(notification), tags: [], created_at: unixNow() } From 555504f42f030028af6b280d664e1024d63e1e12 Mon Sep 17 00:00:00 2001 From: Stixx Date: Mon, 9 Dec 2024 09:44:22 +0100 Subject: [PATCH 05/45] fix: nostr-login custom outbox relays --- src/layouts/Main.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/layouts/Main.tsx b/src/layouts/Main.tsx index 19ac4d9..3e6be26 100644 --- a/src/layouts/Main.tsx +++ b/src/layouts/Main.tsx @@ -134,7 +134,15 @@ export const MainLayout = () => { initNostrLogin({ methods: ['connect', 'extension', 'local'], noBanner: true, - onAuth: handleNostrAuth + onAuth: handleNostrAuth, + outboxRelays: [ + 'wss://purplepag.es', + 'wss://relay.nos.social', + 'wss://user.kindpag.es', + 'wss://relay.damus.io', + 'wss://nos.lol', + 'wss://relay.sigit.io' + ] }).catch((error) => { console.error('Failed to initialize Nostr-Login', error) }) From 7007492a0d1e9d21f505a300aa6b2ca24cf0b585 Mon Sep 17 00:00:00 2001 From: enes Date: Fri, 13 Dec 2024 12:39:00 +0100 Subject: [PATCH 06/45] feat(meta): add error handling for meta.json blossom operations --- src/pages/create/index.tsx | 4 ++-- src/pages/sign/index.tsx | 12 +++++++++++- src/utils/nostr.ts | 8 ++++++-- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index f0c1f0c..63140bf 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -868,11 +868,11 @@ export const CreatePage = () => { setLoadingSpinnerDesc('Updating user app data') - const metaUrl = await uploadMetaToFileStorage(meta, encryptionKey) - const event = await updateUsersAppData(meta) if (!event) return + const metaUrl = await uploadMetaToFileStorage(meta, encryptionKey) + setLoadingSpinnerDesc('Sending notifications to counterparties') const promises = sendNotifications({ metaUrl, diff --git a/src/pages/sign/index.tsx b/src/pages/sign/index.tsx index 346e226..01d5738 100644 --- a/src/pages/sign/index.tsx +++ b/src/pages/sign/index.tsx @@ -753,7 +753,17 @@ export const SignPage = () => { return } - const metaUrl = await uploadMetaToFileStorage(meta, encryptionKey) + let metaUrl: string | undefined + try { + metaUrl = await uploadMetaToFileStorage(meta, encryptionKey) + } catch (error) { + if (error instanceof Error) { + toast.error(error.message) + } + console.error(error) + setIsLoading(false) + return + } const userSet = new Set<`npub1${string}`>() if (submittedBy && submittedBy !== usersPubkey) { diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index ed51fac..0ed4054 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -949,8 +949,12 @@ const processReceivedEvent = async (event: Event, difficulty: number = 5) => { encryptionKey = decrypted } - - meta = await fetchMetaFromFileStorage(notification.metaUrl, encryptionKey) + try { + meta = await fetchMetaFromFileStorage(notification.metaUrl, encryptionKey) + } catch (error) { + console.error(`An error occured fetching meta file from storage`, error) + return + } } else { meta = parsedContent } From e1e5ae7f1aaa55d9ecd9568bbcb8515f9b3e1d4d Mon Sep 17 00:00:00 2001 From: enes Date: Fri, 13 Dec 2024 12:59:50 +0100 Subject: [PATCH 07/45] build(vulnerabilities): bump dependencies with audit fix --- package-lock.json | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index ebeec88..9e14451 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3587,10 +3587,11 @@ "dev": true }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -6254,9 +6255,9 @@ "optional": true }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "dev": true, "funding": [ { @@ -6264,6 +6265,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, From 2a23912c08cf1d7f2665e7b4a74179597cabe957 Mon Sep 17 00:00:00 2001 From: enes Date: Fri, 13 Dec 2024 13:36:44 +0100 Subject: [PATCH 08/45] refactor(sign): autoFocus sign button, use mui/button for focus ripple effect --- src/components/MarkFormField/index.tsx | 15 ++++++++++----- src/components/MarkFormField/style.module.scss | 3 ++- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/components/MarkFormField/index.tsx b/src/components/MarkFormField/index.tsx index 718a119..5f49d27 100644 --- a/src/components/MarkFormField/index.tsx +++ b/src/components/MarkFormField/index.tsx @@ -1,5 +1,4 @@ import { CurrentUserMark } from '../../types/mark.ts' -import styles from './style.module.scss' import { findNextIncompleteCurrentUserMark, getToolboxLabelByMarkType, @@ -10,6 +9,8 @@ import React, { useState } from 'react' import { MarkInput } from '../MarkTypeStrategy/MarkInput.tsx' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faCheck } from '@fortawesome/free-solid-svg-icons' +import { Button } from '@mui/material' +import styles from './style.module.scss' interface MarkFormFieldProps { currentUserMarks: CurrentUserMark[] @@ -123,22 +124,23 @@ const MarkFormField = ({ userMark={selectedMark} />
- +
)} {complete && (
- +
)} @@ -148,6 +150,7 @@ const MarkFormField = ({ return (