diff --git a/src/components/ModCard.tsx b/src/components/ModCard.tsx index 6abd397..49fb142 100644 --- a/src/components/ModCard.tsx +++ b/src/components/ModCard.tsx @@ -5,7 +5,8 @@ import { handleModImageError } from '../utils' import { ModDetails } from 'types' import { getModPageRoute } from 'routes' import { kinds, nip19 } from 'nostr-tools' -import { useDidMount, useNDKContext, useReactions } from 'hooks' +import { useDidMount, useReactions } from 'hooks' +import { RelayController } from 'controllers' import { toast } from 'react-toastify' import { useComments } from 'hooks/useComments' @@ -18,10 +19,10 @@ export const ModCard = React.memo((props: ModDetails) => { eTag: props.id, aTag: props.aTag }) - const { getTotalZapAmount } = useNDKContext() useDidMount(() => { - getTotalZapAmount(props.author, props.id, props.aTag) + RelayController.getInstance() + .getTotalZapAmount(props.author, props.id, props.aTag) .then((res) => { setTotalZappedAmount(res.accumulatedZapAmount) }) @@ -50,13 +51,7 @@ export const ModCard = React.memo((props: ModDetails) => { src={props.featuredImageUrl} onError={handleModImageError} className='cMMPicture' - alt={`featured image for mod ${props.title}`} /> - {props.nsfw && ( -
-

NSFW

-
- )}

{props.title}

diff --git a/src/components/ModForm.tsx b/src/components/ModForm.tsx index 763a3f9..6e90b1a 100644 --- a/src/components/ModForm.tsx +++ b/src/components/ModForm.tsx @@ -13,7 +13,8 @@ import { toast } from 'react-toastify' import { FixedSizeList as List } from 'react-window' import { v4 as uuidv4 } from 'uuid' import { T_TAG_VALUE } from '../constants' -import { useAppSelector, useGames, useNDKContext } from '../hooks' +import { RelayController } from '../controllers' +import { useAppSelector, useGames } from '../hooks' import { appRoutes, getModPageRoute } from '../routes' import '../styles/styles.css' import { DownloadUrl, ModDetails, ModFormState } from '../types' @@ -28,7 +29,6 @@ import { } from '../utils' import { CheckboxField, InputError, InputField } from './Inputs' import { LoadingSpinner } from './LoadingSpinner' -import { NDKEvent } from '@nostr-dev-kit/ndk' interface FormErrors { game?: string @@ -54,7 +54,6 @@ type ModFormProps = { export const ModForm = ({ existingModData }: ModFormProps) => { const location = useLocation() const navigate = useNavigate() - const { ndk, publish } = useNDKContext() const games = useGames() const userState = useAppSelector((state) => state.user) @@ -244,8 +243,9 @@ export const ModForm = ({ existingModData }: ModFormProps) => { return } - const ndkEvent = new NDKEvent(ndk, signedEvent) - const publishedOnRelays = await publish(ndkEvent) + const publishedOnRelays = await RelayController.getInstance().publish( + signedEvent as Event + ) // Handle cases where publishing failed or succeeded if (publishedOnRelays.length === 0) { @@ -763,9 +763,8 @@ const GameDropdown = ({

- Can't find the game you're looking for? You can temporarily publish the - mod under '(Unlisted Game)' and later edit it with the proper game name - once we add it. + Can't find the game you're looking for? You can temporarily publish the mod under '(Unlisted Game)' and + later edit it with the proper game name once we add it.

@@ -826,12 +825,10 @@ const GameDropdown = ({
-
+
{error && } -

- Note: Please mention the game name in the body text of your mod post - (e.g., 'This is a mod for Game Name') so we know what to look for and - add. +

Note: Please mention the game name in the body text of your mod post (e.g., 'This is a mod for Game Name') + so we know what to look for and add.

) diff --git a/src/components/ProfileSection.tsx b/src/components/ProfileSection.tsx index 323d5a6..170c1e4 100644 --- a/src/components/ProfileSection.tsx +++ b/src/components/ProfileSection.tsx @@ -4,17 +4,13 @@ import { QRCodeSVG } from 'qrcode.react' import { useState } from 'react' import { Link } from 'react-router-dom' import { toast } from 'react-toastify' -import { - useAppSelector, - useBodyScrollDisable, - useDidMount, - useNDKContext -} from '../hooks' +import { RelayController, UserRelaysType } from '../controllers' +import { useAppSelector, useDidMount, useNDKContext } from '../hooks' import { appRoutes, getProfilePageRoute } from '../routes' import '../styles/author.css' import '../styles/innerPage.css' import '../styles/socialPosts.css' -import { UserProfile, UserRelaysType } from '../types' +import { UserProfile } from '../types' import { copyTextToClipboard, hexToNpub, @@ -26,7 +22,6 @@ import { import { LoadingSpinner } from './LoadingSpinner' import { ZapPopUp } from './Zap' import placeholder from '../assets/img/DEGMods Placeholder Img.png' -import { NDKEvent } from '@nostr-dev-kit/ndk' type Props = { pubkey: string @@ -259,8 +254,6 @@ export const ProfileQRButtonWithPopUp = ({ }: QRButtonWithPopUpProps) => { const [isOpen, setIsOpen] = useState(false) - useBodyScrollDisable(isOpen) - const nprofile = nip19.nprofileEncode({ pubkey }) @@ -342,8 +335,6 @@ type ZapButtonWithPopUpProps = { const ZapButtonWithPopUp = ({ pubkey }: ZapButtonWithPopUpProps) => { const [isOpen, setIsOpen] = useState(false) - useBodyScrollDisable(isOpen) - return ( <>
{ - const { ndk, fetchEventFromUserRelays, publish } = useNDKContext() + const { fetchEventFromUserRelays } = useNDKContext() const [isFollowing, setIsFollowing] = useState(false) const [isLoading, setIsLoading] = useState(false) @@ -450,8 +441,9 @@ const FollowButton = ({ pubkey }: FollowButtonProps) => { if (!signedEvent) return false - const ndkEvent = new NDKEvent(ndk, signedEvent) - const publishedOnRelays = await publish(ndkEvent) + const publishedOnRelays = await RelayController.getInstance().publish( + signedEvent as Event + ) if (publishedOnRelays.length === 0) { toast.error('Failed to publish event on any relay') diff --git a/src/components/Zap.tsx b/src/components/Zap.tsx index c393947..e7c7005 100644 --- a/src/components/Zap.tsx +++ b/src/components/Zap.tsx @@ -1,4 +1,3 @@ -import { getRelayListForUser } from '@nostr-dev-kit/ndk' import { QRCodeSVG } from 'qrcode.react' import React, { Dispatch, @@ -10,7 +9,7 @@ import React, { } from 'react' import Countdown, { CountdownRenderProps } from 'react-countdown' import { toast } from 'react-toastify' -import { ZapController } from '../controllers' +import { MetadataController, ZapController } from '../controllers' import { useAppSelector, useDidMount, useNDKContext } from '../hooks' import '../styles/popup.css' import { PaymentRequest, UserProfile } from '../types' @@ -252,7 +251,7 @@ export const ZapPopUp = ({ setHasZapped, handleClose }: ZapPopUpProps) => { - const { ndk, findMetadata } = useNDKContext() + const { findMetadata } = useNDKContext() const [isLoading, setIsLoading] = useState(false) const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') const [amount, setAmount] = useState(0) @@ -301,20 +300,6 @@ export const ZapPopUp = ({ return null } - // Find the receiver's read relays. - const receiverRelays = await getRelayListForUser(receiver, ndk) - .then((ndkRelayList) => { - if (ndkRelayList) return ndkRelayList.readRelayUrls - return [] // Return an empty array if ndkRelayList is undefined - }) - .catch((err) => { - console.error( - `An error occurred in getting zap receiver's read relays`, - err - ) - return [] as string[] - }) - const zapController = ZapController.getInstance() setLoadingSpinnerDesc('Creating zap request') @@ -323,7 +308,6 @@ export const ZapPopUp = ({ receiverMetadata.lud16, amount, receiverMetadata.pubkey as string, - receiverRelays, userHexKey, message, eventId, @@ -336,7 +320,7 @@ export const ZapPopUp = ({ .finally(() => { setIsLoading(false) }) - }, [amount, message, userState, receiver, eventId, aTag, ndk, findMetadata]) + }, [amount, message, userState, receiver, eventId, aTag]) const handleGenerateQRCode = async () => { const pr = await generatePaymentRequest() @@ -498,7 +482,7 @@ export const ZapSplit = ({ setHasZapped, handleClose }: ZapSplitProps) => { - const { ndk, findMetadata } = useNDKContext() + const { findMetadata } = useNDKContext() const [isLoading, setIsLoading] = useState(false) const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') const [amount, setAmount] = useState(0) @@ -518,8 +502,8 @@ export const ZapSplit = ({ setAuthor(res) }) - const adminNpubs = import.meta.env.VITE_ADMIN_NPUBS.split(',') - findMetadata(adminNpubs[0]).then((res) => { + const metadataController = await MetadataController.getInstance() + findMetadata(metadataController.adminNpubs[0]).then((res) => { setAdmin(res) }) }) @@ -573,30 +557,12 @@ export const ZapSplit = ({ const invoices = new Map() if (authorShare > 0 && author?.pubkey && author?.lud16) { - // Find the receiver's read relays. - const authorRelays = await getRelayListForUser( - author.pubkey as string, - ndk - ) - .then((ndkRelayList) => { - if (ndkRelayList) return ndkRelayList.readRelayUrls - return [] // Return an empty array if ndkRelayList is undefined - }) - .catch((err) => { - console.error( - `An error occurred in getting zap receiver's read relays`, - err - ) - return [] as string[] - }) - setLoadingSpinnerDesc('Generating invoice for author') const invoice = await zapController .getLightningPaymentRequest( author.lud16, authorShare, author.pubkey as string, - authorRelays, userHexKey, message, eventId, @@ -613,27 +579,12 @@ export const ZapSplit = ({ } if (adminShare > 0 && admin?.pubkey && admin?.lud16) { - // Find the receiver's read relays. - const adminRelays = await getRelayListForUser(admin.pubkey as string, ndk) - .then((ndkRelayList) => { - if (ndkRelayList) return ndkRelayList.readRelayUrls - return [] // Return an empty array if ndkRelayList is undefined - }) - .catch((err) => { - console.error( - `An error occurred in getting zap receiver's read relays`, - err - ) - return [] as string[] - }) - setLoadingSpinnerDesc('Generating invoice for site owner') const invoice = await zapController .getLightningPaymentRequest( admin.lud16, adminShare, admin.pubkey as string, - adminRelays, userHexKey, message, eventId, diff --git a/src/contexts/NDKContext.tsx b/src/contexts/NDKContext.tsx index 33e35da..0ec3771 100644 --- a/src/contexts/NDKContext.tsx +++ b/src/contexts/NDKContext.tsx @@ -3,18 +3,17 @@ import NDK, { NDKEvent, NDKFilter, NDKKind, - NDKList, NDKRelaySet, NDKSubscriptionCacheUsage, - NDKUser, - zapInvoiceFromEvent + NDKUser } from '@nostr-dev-kit/ndk' import NDKCacheAdapterDexie from '@nostr-dev-kit/ndk-cache-dexie' import { MOD_FILTER_LIMIT, T_TAG_VALUE } from 'constants.ts' +import { UserRelaysType } from 'controllers' import { Dexie } from 'dexie' import { createContext, ReactNode, useEffect, useMemo } from 'react' import { toast } from 'react-toastify' -import { ModDetails, MuteLists, UserProfile, UserRelaysType } from 'types' +import { ModDetails, UserProfile } from 'types' import { constructModListFromEvents, hexToNpub, @@ -34,34 +33,23 @@ type FetchModsOptions = { interface NDKContextType { ndk: NDK fetchMods: (opts: FetchModsOptions) => Promise - fetchEvents: (filter: NDKFilter) => Promise - fetchEvent: (filter: NDKFilter) => Promise + fetchEvents: (filter: NDKFilter, relayUrls?: string[]) => Promise + fetchEvent: ( + filter: NDKFilter, + relayUrls?: string[] + ) => Promise + fetchEventsFromUserRelays: ( - filter: NDKFilter | NDKFilter[], + filter: NDKFilter, hexKey: string, userRelaysType: UserRelaysType ) => Promise fetchEventFromUserRelays: ( - filter: NDKFilter | NDKFilter[], + filter: NDKFilter, hexKey: string, userRelaysType: UserRelaysType ) => Promise findMetadata: (pubkey: string) => Promise - getTotalZapAmount: ( - user: string, - eTag: string, - aTag?: string, - currentLoggedInUser?: string - ) => Promise<{ - accumulatedZapAmount: number - hasZapped: boolean - }> - publish: (event: NDKEvent) => Promise - getNSFWList: () => Promise - getMuteLists: (pubkey?: string) => Promise<{ - admin: MuteLists - user: MuteLists - }> } // Create the context with an initial value of `null` @@ -84,31 +72,6 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => { } }, []) - const addAdminRelays = async (ndk: NDK) => { - const adminNpubs = import.meta.env.VITE_ADMIN_NPUBS.split(',') - adminNpubs.forEach((npub) => { - const hexKey = npubToHex(npub) - if (hexKey) { - getRelayListForUser(hexKey, ndk) - .then((ndkRelayList) => { - if (ndkRelayList) { - ndkRelayList.bothRelayUrls.forEach((url) => - ndk.addExplicitRelay(url) - ) - } - }) - .catch((err) => { - log( - true, - LogType.Error, - `❌ Error occurred in getting the instance of NDKRelayList for npub: ${npub}`, - err - ) - }) - } - }) - } - const ndk = useMemo(() => { localStorage.setItem('debug', '*') const dexieAdapter = new NDKCacheAdapterDexie({ dbName: 'degmod-db' }) @@ -125,7 +88,6 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => { ], cacheAdapter: dexieAdapter }) - addAdminRelays(ndk) ndk.connect() @@ -148,6 +110,33 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => { since, limit }: FetchModsOptions): Promise => { + const relays = new Set() + relays.add(import.meta.env.VITE_APP_RELAY) + + const adminNpubs = import.meta.env.VITE_ADMIN_NPUBS.split(',') + + const promises = adminNpubs.map((npub) => { + const hexKey = npubToHex(npub) + if (!hexKey) return null + + return getRelayListForUser(hexKey, ndk) + .then((ndkRelayList) => { + if (ndkRelayList) { + ndkRelayList.writeRelayUrls.forEach((url) => relays.add(url)) + } + }) + .catch((err) => { + log( + true, + LogType.Error, + `❌ Error occurred in getting the instance of NDKRelayList for npub: ${npub}`, + err + ) + }) + }) + + await Promise.allSettled(promises) + // Define the filter criteria for fetching mods const filter: NDKFilter = { kinds: [NDKKind.Classified], // Specify the kind of events to fetch @@ -163,10 +152,11 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => { } return ndk - .fetchEvents(filter, { - closeOnEose: true, - cacheUsage: NDKSubscriptionCacheUsage.PARALLEL - }) + .fetchEvents( + filter, + { closeOnEose: true, cacheUsage: NDKSubscriptionCacheUsage.PARALLEL }, + NDKRelaySet.fromRelayUrls(Array.from(relays), ndk, true) + ) .then((ndkEventSet) => { const ndkEvents = Array.from(ndkEventSet) orderEventsChronologically(ndkEvents) @@ -189,17 +179,56 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => { } /** - * Asynchronously retrieves multiple event based on a provided filter. + * 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 to the found event or null if not found. */ - const fetchEvents = async (filter: NDKFilter): Promise => { + const fetchEvents = async ( + filter: NDKFilter, + relayUrls: string[] = [] + ): Promise => { + const relays = new Set() + + // add all the relays passed to relay set + relayUrls.forEach((relayUrl) => { + relays.add(relayUrl) + }) + + relays.add(import.meta.env.VITE_APP_RELAY) + + const adminNpubs = import.meta.env.VITE_ADMIN_NPUBS.split(',') + + const promises = adminNpubs.map((npub) => { + const hexKey = npubToHex(npub) + if (!hexKey) return null + + return getRelayListForUser(hexKey, ndk) + .then((ndkRelayList) => { + if (ndkRelayList) { + ndkRelayList.writeRelayUrls.forEach((url) => relays.add(url)) + } + }) + .catch((err) => { + log( + true, + LogType.Error, + `❌ Error occurred in getting the instance of NDKRelayList for npub: ${npub}`, + err + ) + }) + }) + + await Promise.allSettled(promises) + return ndk - .fetchEvents(filter, { - closeOnEose: true, - cacheUsage: NDKSubscriptionCacheUsage.PARALLEL - }) + .fetchEvents( + filter, + { closeOnEose: true, cacheUsage: NDKSubscriptionCacheUsage.PARALLEL }, + NDKRelaySet.fromRelayUrls(Array.from(relays), ndk, true) + ) .then((ndkEventSet) => { const ndkEvents = Array.from(ndkEventSet) return orderEventsChronologically(ndkEvents) @@ -213,13 +242,15 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => { } /** - * Asynchronously retrieves an event based on a provided filter. + * 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 relaysUrls - 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. */ - const fetchEvent = async (filter: NDKFilter) => { - const events = await fetchEvents(filter) + const fetchEvent = async (filter: NDKFilter, relayUrls: string[] = []) => { + const events = await fetchEvents(filter, relayUrls) if (events.length === 0) return null return events[0] } @@ -234,10 +265,10 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => { * @returns A promise that resolves with an array of events. */ const fetchEventsFromUserRelays = async ( - filter: NDKFilter | NDKFilter[], + filter: NDKFilter, hexKey: string, userRelaysType: UserRelaysType - ): Promise => { + ) => { // Find the user's relays. const relayUrls = await getRelayListForUser(hexKey, ndk) .then((ndkRelayList) => { @@ -254,22 +285,8 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => { return [] as string[] }) - return ndk - .fetchEvents( - filter, - { closeOnEose: true, cacheUsage: NDKSubscriptionCacheUsage.PARALLEL }, - NDKRelaySet.fromRelayUrls(relayUrls, ndk, true) - ) - .then((ndkEventSet) => { - const ndkEvents = Array.from(ndkEventSet) - return orderEventsChronologically(ndkEvents) - }) - .catch((err) => { - // Log the error and show a notification if fetching fails - log(true, LogType.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 - }) + // Fetch the event from the user's relays using the provided filter and relay URLs + return fetchEvents(filter, relayUrls) } /** @@ -282,7 +299,7 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => { * @returns A promise that resolves to the fetched event or null if the operation fails. */ const fetchEventFromUserRelays = async ( - filter: NDKFilter | NDKFilter[], + filter: NDKFilter, hexKey: string, userRelaysType: UserRelaysType ) => { @@ -312,178 +329,6 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => { return userProfile } - const getTotalZapAmount = async ( - user: string, - eTag: string, - aTag?: string, - currentLoggedInUser?: string - ) => { - const filters: NDKFilter[] = [ - { - kinds: [NDKKind.Zap], - '#e': [eTag], - '#p': [user] - } - ] - - if (aTag) { - filters.push({ - kinds: [NDKKind.Zap], - '#a': [aTag], - '#p': [user] - }) - } - - const zapEvents = await fetchEventsFromUserRelays( - filters, - user, - UserRelaysType.Read - ) - - let accumulatedZapAmount = 0 - let hasZapped = false - - zapEvents.forEach((zap) => { - const zapInvoice = zapInvoiceFromEvent(zap) - if (zapInvoice) { - accumulatedZapAmount += Math.round(zapInvoice.amount / 1000) - - if (!hasZapped) hasZapped = zapInvoice.zappee === currentLoggedInUser - } - }) - - return { - accumulatedZapAmount, - hasZapped - } - } - - const publish = async (event: NDKEvent): Promise => { - if (!event.sig) throw new Error('Before publishing first sign the event!') - - return event - .publish(undefined, 30000) - .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 [] - }) - } - - /** - * Retrieves a list of NSFW (Not Safe For Work) posts that were not specified as NSFW by post author but marked as NSFW by admin. - * - * @returns {Promise} - A promise that resolves to an array of NSFW post identifiers (e.g., URLs or IDs). - */ - const getNSFWList = async (): Promise => { - // Initialize an array to store the NSFW post identifiers - const nsfwPosts: string[] = [] - - const reportingNpub = import.meta.env.VITE_REPORTING_NPUB - - // Convert the public key (npub) to a hexadecimal format - const hexKey = npubToHex(reportingNpub) - - // If the conversion is successful and we have a hexKey - if (hexKey) { - // Fetch the event that contains the NSFW list - const nsfwListEvent = await fetchEvent({ - kinds: [NDKKind.ArticleCurationSet], - authors: [hexKey], - '#d': ['nsfw'] - }) - - if (nsfwListEvent) { - // Convert the event data to an NDKList, which is a structured list format - const list = NDKList.from(nsfwListEvent) - - // Iterate through the items in the list - list.items.forEach((item) => { - if (item[0] === 'a') { - // Add the identifier of the NSFW post to the nsfwPosts array - nsfwPosts.push(item[1]) - } - }) - } - } - - // Return the array of NSFW post identifiers - return nsfwPosts - } - - const getMuteLists = async ( - pubkey?: string - ): Promise<{ - admin: MuteLists - user: MuteLists - }> => { - const adminMutedAuthors = new Set() - const adminMutedPosts = new Set() - - const reportingNpub = import.meta.env.VITE_REPORTING_NPUB - - const adminHexKey = npubToHex(reportingNpub) - - if (adminHexKey) { - const muteListEvent = await fetchEvent({ - kinds: [NDKKind.MuteList], - authors: [adminHexKey] - }) - - if (muteListEvent) { - const list = NDKList.from(muteListEvent) - - list.items.forEach((item) => { - if (item[0] === 'p') { - adminMutedAuthors.add(item[1]) - } else if (item[0] === 'a') { - adminMutedPosts.add(item[1]) - } - }) - } - } - - const userMutedAuthors = new Set() - const userMutedPosts = new Set() - - if (pubkey) { - const userHexKey = npubToHex(pubkey) - - if (userHexKey) { - const muteListEvent = await fetchEvent({ - kinds: [NDKKind.MuteList], - authors: [userHexKey] - }) - - if (muteListEvent) { - const list = NDKList.from(muteListEvent) - - list.items.forEach((item) => { - if (item[0] === 'p') { - userMutedAuthors.add(item[1]) - } else if (item[0] === 'a') { - userMutedPosts.add(item[1]) - } - }) - } - } - } - - return { - admin: { - authors: Array.from(adminMutedAuthors), - replaceableEvents: Array.from(adminMutedPosts) - }, - user: { - authors: Array.from(userMutedAuthors), - replaceableEvents: Array.from(userMutedPosts) - } - } - } - return ( { fetchEvent, fetchEventsFromUserRelays, fetchEventFromUserRelays, - findMetadata, - getTotalZapAmount, - publish, - getNSFWList, - getMuteLists + findMetadata }} > {children} diff --git a/src/controllers/index.ts b/src/controllers/index.ts index 4e84779..b7b89e4 100644 --- a/src/controllers/index.ts +++ b/src/controllers/index.ts @@ -1 +1,3 @@ +export * from './metadata' +export * from './relay' export * from './zap' diff --git a/src/controllers/metadata.ts b/src/controllers/metadata.ts new file mode 100644 index 0000000..98967d4 --- /dev/null +++ b/src/controllers/metadata.ts @@ -0,0 +1,217 @@ +import NDK, { getRelayListForUser, NDKList } from '@nostr-dev-kit/ndk' +import { kinds } from 'nostr-tools' +import { MuteLists } from '../types' +import { log, LogType, npubToHex, timeout } from '../utils' + +export enum UserRelaysType { + Read = 'readRelayUrls', + Write = 'writeRelayUrls', + Both = 'bothRelayUrls' +} + +/** + * Singleton class to manage metadata operations using NDK. + */ +export class MetadataController { + private static instance: MetadataController + private ndk: NDK + public adminNpubs: string[] + public adminRelays = new Set() + public reportingNpub: string + + private constructor() { + this.ndk = new NDK({ + explicitRelayUrls: [ + 'wss://user.kindpag.es', + 'wss://purplepag.es', + 'wss://relay.damus.io/', + import.meta.env.VITE_APP_RELAY + ] + }) + + this.ndk + .connect() + .then(() => { + console.log('NDK connected') + }) + .catch((err) => { + console.log('error in ndk connection', err) + }) + + this.adminNpubs = import.meta.env.VITE_ADMIN_NPUBS.split(',') + this.reportingNpub = import.meta.env.VITE_REPORTING_NPUB + } + + private setAdminRelays = async () => { + const promises = this.adminNpubs.map((npub) => { + const hexKey = npubToHex(npub) + if (!hexKey) return null + + return getRelayListForUser(hexKey, this.ndk) + .then((ndkRelayList) => { + if (ndkRelayList) { + ndkRelayList.writeRelayUrls.forEach((url) => + this.adminRelays.add(url) + ) + } + }) + .catch((err) => { + log( + true, + LogType.Error, + `❌ Error occurred in getting the instance of NDKRelayList for npub: ${npub}`, + err + ) + }) + }) + + await Promise.allSettled(promises) + } + + /** + * Provides the singleton instance of MetadataController. + * + * @returns The singleton instance of MetadataController. + */ + public static async getInstance(): Promise { + if (!MetadataController.instance) { + MetadataController.instance = new MetadataController() + + await MetadataController.instance.setAdminRelays() + } + return MetadataController.instance + } + + public findUserRelays = async ( + hexKey: string, + userRelaysType: UserRelaysType = UserRelaysType.Both + ): Promise => { + log(true, LogType.Info, `ℹ Finding user's relays`, hexKey, userRelaysType) + + const ndkRelayListPromise = getRelayListForUser(hexKey, this.ndk) + + // Use Promise.race to either get the NDKRelayList instance or handle the timeout + return await Promise.race([ + ndkRelayListPromise, + timeout() // Custom timeout function that rejects after a specified time + ]) + .then((ndkRelayList) => { + if (ndkRelayList) return ndkRelayList[userRelaysType] + return [] // Return an empty array if ndkRelayList is undefined + }) + .catch((err) => { + log(true, LogType.Error, err) + return [] // Return an empty array if an error occurs + }) + } + + public getNDKRelayList = async (hexKey: string) => + getRelayListForUser(hexKey, this.ndk) + + public getMuteLists = async ( + pubkey?: string + ): Promise<{ + admin: MuteLists + user: MuteLists + }> => { + const adminMutedAuthors = new Set() + const adminMutedPosts = new Set() + + const adminHexKey = npubToHex(this.reportingNpub) + + if (adminHexKey) { + const muteListEvent = await this.ndk.fetchEvent({ + kinds: [kinds.Mutelist], + authors: [adminHexKey] + }) + + if (muteListEvent) { + const list = NDKList.from(muteListEvent) + + list.items.forEach((item) => { + if (item[0] === 'p') { + adminMutedAuthors.add(item[1]) + } else if (item[0] === 'a') { + adminMutedPosts.add(item[1]) + } + }) + } + } + + const userMutedAuthors = new Set() + const userMutedPosts = new Set() + + if (pubkey) { + const userHexKey = npubToHex(pubkey) + + if (userHexKey) { + const muteListEvent = await this.ndk.fetchEvent({ + kinds: [kinds.Mutelist], + authors: [userHexKey] + }) + + if (muteListEvent) { + const list = NDKList.from(muteListEvent) + + list.items.forEach((item) => { + if (item[0] === 'p') { + userMutedAuthors.add(item[1]) + } else if (item[0] === 'a') { + userMutedPosts.add(item[1]) + } + }) + } + } + } + + return { + admin: { + authors: Array.from(adminMutedAuthors), + replaceableEvents: Array.from(adminMutedPosts) + }, + user: { + authors: Array.from(userMutedAuthors), + replaceableEvents: Array.from(userMutedPosts) + } + } + } + + /** + * Retrieves a list of NSFW (Not Safe For Work) posts that were not specified as NSFW by post author but marked as NSFW by admin. + * + * @returns {Promise} - A promise that resolves to an array of NSFW post identifiers (e.g., URLs or IDs). + */ + public getNSFWList = async (): Promise => { + // Initialize an array to store the NSFW post identifiers + const nsfwPosts: string[] = [] + + // Convert the public key (npub) to a hexadecimal format + const hexKey = npubToHex(this.reportingNpub) + + // If the conversion is successful and we have a hexKey + if (hexKey) { + // Fetch the event that contains the NSFW list + const nsfwListEvent = await this.ndk.fetchEvent({ + kinds: [kinds.Curationsets], + authors: [hexKey], + '#d': ['nsfw'] + }) + + if (nsfwListEvent) { + // Convert the event data to an NDKList, which is a structured list format + const list = NDKList.from(nsfwListEvent) + + // Iterate through the items in the list + list.items.forEach((item) => { + if (item[0] === 'a') { + // Add the identifier of the NSFW post to the nsfwPosts array + nsfwPosts.push(item[1]) + } + }) + } + } + + // Return the array of NSFW post identifiers + return nsfwPosts + } +} diff --git a/src/controllers/relay.ts b/src/controllers/relay.ts new file mode 100644 index 0000000..2fdd6ae --- /dev/null +++ b/src/controllers/relay.ts @@ -0,0 +1,561 @@ +import { Event, Filter, kinds, nip57, Relay } from 'nostr-tools' +import { + extractZapAmount, + log, + LogType, + normalizeWebSocketURL, + timeout +} from '../utils' +import { MetadataController, UserRelaysType } from './metadata' + +/** + * Singleton class to manage relay operations. + */ +export class RelayController { + private static instance: RelayController + private events = new Map() + private debug = true + public connectedRelays: 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 + } + + public connectRelay = async (relayUrl: string) => { + const relay = this.connectedRelays.find( + (relay) => + normalizeWebSocketURL(relay.url) === normalizeWebSocketURL(relayUrl) + ) + if (relay) { + // already connected, skip + return relay + } + + return await Relay.connect(relayUrl) + .then((relay) => { + log(this.debug, LogType.Info, `✅ nostr (${relayUrl}): Connected!`) + this.connectedRelays.push(relay) + return relay + }) + .catch((err) => { + log( + this.debug, + LogType.Error, + `❌ nostr (${relayUrl}): Connection error!`, + err + ) + return null + }) + } + + /** + * Publishes an event to multiple relays. + * + * This method establishes a connection to the application relay specified by + * an environment variable and a set of relays obtained from the + * `MetadataController`. It attempts to publish the event to all connected + * relays and returns a list of URLs of relays where the event was successfully + * published. + * + * If the process of finding relays or publishing the event takes too long, + * it handles the timeout to prevent blocking the operation. + * + * @param event - The event to be published. + * @param userHexKey - The user's hexadecimal public key, used to retrieve their relays. + * If not provided, the event's public key will be used. + * @param userRelaysType - The type of relays to be retrieved (e.g., write relays). + * Defaults to `UserRelaysType.Write`. + * @returns A promise that resolves to an array of URLs of relays where the event + * was published, or an empty array if no relays were connected or the + * event could not be published. + */ + publish = async ( + event: Event, + userHexKey?: string, + userRelaysType?: UserRelaysType + ): Promise => { + // Connect to the application relay specified by an environment variable + const appRelayPromise = this.connectRelay(import.meta.env.VITE_APP_RELAY) + + // TODO: Implement logic to retrieve relays using `window.nostr.getRelays()` once it becomes available in nostr-login. + + // Retrieve an instance of MetadataController to find user relays + const metadataController = await MetadataController.getInstance() + + // Retrieve the list of relays for the specified user's public key + const relayUrls = await metadataController.findUserRelays( + userHexKey || event.pubkey, + userRelaysType || UserRelaysType.Write + ) + + // Add admin relay URLs from the metadata controller to the list of relay URLs + metadataController.adminRelays.forEach((url) => { + relayUrls.push(url) + }) + + // Attempt to connect to all write relays obtained from MetadataController + const relayPromises = relayUrls.map((relayUrl) => + this.connectRelay(relayUrl) + ) + + // Wait for all relay connection attempts to settle (either fulfilled or rejected) + const results = await Promise.allSettled([ + appRelayPromise, + ...relayPromises + ]) + + // Extract non-null values from fulfilled promises in a single pass + const relays = results.reduce((acc, result) => { + if (result.status === 'fulfilled') { + const value = result.value + if (value) { + acc.push(value) + } + } + return acc + }, []) + + // If no relays are connected, log an error and return an empty array + if (relays.length === 0) { + log(this.debug, LogType.Error, 'No relay is connected!') + return [] + } + + const publishedOnRelays: string[] = [] // Track relays where the event was successfully published + + // Create promises to publish the event to each connected relay + const publishPromises = relays.map((relay) => { + log( + this.debug, + LogType.Info, + `⬆️ nostr (${relay.url}): Sending event:`, + event + ) + + return Promise.race([ + relay.publish(event), // Publish the event to the relay + timeout(30000) // Set a timeout to handle slow publishing operations + ]) + .then((res) => { + log( + this.debug, + LogType.Info, + `⬆️ nostr (${relay.url}): Publish result:`, + res + ) + publishedOnRelays.push(relay.url) // Add successful relay URL to the list + }) + .catch((err) => { + log( + this.debug, + LogType.Error, + `❌ nostr (${relay.url}): Publish error!`, + err + ) + }) + }) + + // Wait for all publish operations to complete (either fulfilled or rejected) + await Promise.allSettled(publishPromises) + + if (publishedOnRelays.length > 0) { + // If the event was successfully published to any relays, check if it contains an `aTag` + // If the `aTag` is present, cache the event locally + const aTag = event.tags.find((item) => item[0] === 'a') + if (aTag && aTag[1]) { + this.events.set(aTag[1], event) + } + } + + // Return the list of relay URLs where the event was successfully published + return publishedOnRelays + } + + /** + * Publishes an encrypted DM to receiver's read relays. + * + * This method connects to the application relay and a set of receiver's read relays + * obtained from the `MetadataController`. It then publishes the event to + * all connected relays and returns a list of relays where the event was successfully published. + * + * @param event - The event to be published. + * @returns A promise that resolves to an array of URLs of relays where the event was published, + * or an empty array if no relays were connected or the event could not be published. + */ + publishDM = async (event: Event, receiver: string): Promise => { + // Connect to the application relay specified by environment variable + const appRelayPromise = this.connectRelay(import.meta.env.VITE_APP_RELAY) + + // todo: window.nostr.getRelays() is not implemented yet in nostr-login, implement the logic once its done + + const metadataController = await MetadataController.getInstance() + + // Retrieve the list of read relays for the receiver + const readRelayUrls = await metadataController.findUserRelays( + receiver, + UserRelaysType.Read + ) + + // push admin relay urls obtained from metadata controller to readRelayUrls list + metadataController.adminRelays.forEach((url) => { + readRelayUrls.push(url) + }) + + // Connect to all write relays obtained from MetadataController + const relayPromises = readRelayUrls.map((relayUrl) => + this.connectRelay(relayUrl) + ) + + // Wait for all relay connections to settle (either fulfilled or rejected) + await Promise.allSettled([appRelayPromise, ...relayPromises]) + + // Check if any relays are connected; if not, log an error and return null + if (this.connectedRelays.length === 0) { + log(this.debug, LogType.Error, 'No relay is connected!') + return [] + } + + 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 = this.connectedRelays.map((relay) => { + log( + this.debug, + LogType.Info, + `⬆️ nostr (${relay.url}): Sending event:`, + event + ) + + return Promise.race([ + relay.publish(event), // Publish the event to the relay + timeout(30000) // Set a timeout to handle cases where publishing takes too long + ]) + .then((res) => { + log( + this.debug, + LogType.Info, + `⬆️ nostr (${relay.url}): Publish result:`, + res + ) + publishedOnRelays.push(relay.url) // Add the relay URL to the list of successfully published relays + }) + .catch((err) => { + log( + this.debug, + LogType.Error, + `❌ nostr (${relay.url}): Publish error!`, + 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 + } + + /** + * Publishes an event to multiple relays. + * + * This method establishes a connection to the application relay specified by + * an environment variable and a set of relays provided as argument. + * It attempts to publish the event to all connected relays + * and returns a list of URLs of relays where the event was successfully published. + * + * If the process of publishing the event takes too long, + * it handles the timeout to prevent blocking the operation. + * + * @param event - The event to be published. + * @param relayUrls - The array of relayUrl where event should be published + * @returns A promise that resolves to an array of URLs of relays where the event + * was published, or an empty array if no relays were connected or the + * event could not be published. + */ + publishOnRelays = async ( + event: Event, + relayUrls: string[] + ): Promise => { + const appRelay = import.meta.env.VITE_APP_RELAY + + if (!relayUrls.includes(appRelay)) { + /** + * 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, appRelay] // Add app relay to relays array if not exists already + } + + // connect to all specified relays + const relayPromises = relayUrls.map((relayUrl) => + this.connectRelay(relayUrl) + ) + + // Use Promise.allSettled to wait for all promises to settle + const results = await Promise.allSettled(relayPromises) + + // Extract non-null values from fulfilled promises in a single pass + const relays = results.reduce((acc, result) => { + if (result.status === 'fulfilled') { + const value = result.value + if (value) { + acc.push(value) + } + } + return acc + }, []) + + // Check if any relays are connected + if (relays.length === 0) { + log(this.debug, LogType.Error, 'No relay is connected!') + return [] + } + + const publishedOnRelays: string[] = [] // Track relays where the event was successfully published + + // Create promises to publish the event to each connected relay + const publishPromises = relays.map((relay) => { + log( + this.debug, + LogType.Info, + `⬆️ nostr (${relay.url}): Sending event:`, + event + ) + + return Promise.race([ + relay.publish(event), // Publish the event to the relay + timeout(30000) // Set a timeout to handle slow publishing operations + ]) + .then((res) => { + log( + this.debug, + LogType.Info, + `⬆️ nostr (${relay.url}): Publish result:`, + res + ) + publishedOnRelays.push(relay.url) // Add successful relay URL to the list + }) + .catch((err) => { + log( + this.debug, + LogType.Error, + `❌ nostr (${relay.url}): Publish error!`, + err + ) + }) + }) + + // Wait for all publish operations to complete (either fulfilled or rejected) + await Promise.allSettled(publishPromises) + + if (publishedOnRelays.length > 0) { + // If the event was successfully published to any relays, check if it contains an `aTag` + // If the `aTag` is present, cache the event locally + const aTag = event.tags.find((item) => item[0] === 'a') + if (aTag && aTag[1]) { + this.events.set(aTag[1], event) + } + } + + // Return the list of relay URLs where the event was successfully published + return publishedOnRelays + } + + /** + * Subscribes to events from multiple relays. + * + * This method connects to the specified relay URLs and subscribes to events + * using the provided filter. It handles incoming events through the given + * `eventHandler` callback and manages the subscription lifecycle. + * + * @param filter - The filter criteria to apply when subscribing to events. + * @param relayUrls - An optional array of relay URLs to connect to. The default relay URL (`APP_RELAY`) is added automatically. + * @param eventHandler - A callback function to handle incoming events. It receives an `Event` object. + * + */ + subscribeForEvents = async ( + filter: Filter, + relayUrls: string[] = [], + eventHandler: (event: Event) => void + ) => { + const appRelay = import.meta.env.VITE_APP_RELAY + if (!relayUrls.includes(appRelay)) { + /** + * 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, appRelay] // Add app relay to relays array if not exists already + } + + // connect to all specified relays + const relayPromises = relayUrls.map((relayUrl) => + this.connectRelay(relayUrl) + ) + + // Use Promise.allSettled to wait for all promises to settle + const results = await Promise.allSettled(relayPromises) + + // Extract non-null values from fulfilled promises in a single pass + const relays = results.reduce((acc, result) => { + if (result.status === 'fulfilled') { + const value = result.value + if (value) { + acc.push(value) + } + } + return acc + }, []) + + // Check if any relays are connected + if (relays.length === 0) { + throw new Error('No relay is connected to fetch events!') + } + + const processedEvents: string[] = [] // To keep track of processed events + + // Create a promise for each relay subscription + const subscriptions = relays.map((relay) => + relay.subscribe([filter], { + // Handle incoming events + onevent: (e) => { + // Process event only if it hasn't been processed before + if (!processedEvents.includes(e.id)) { + processedEvents.push(e.id) + eventHandler(e) // Call the event handler with the event + } + } + }) + ) + + return subscriptions + } + + getTotalZapAmount = async ( + user: string, + eTag: string, + aTag?: string, + currentLoggedInUser?: string + ) => { + const metadataController = await MetadataController.getInstance() + + const relayUrls = await metadataController.findUserRelays( + user, + UserRelaysType.Read + ) + + const appRelay = import.meta.env.VITE_APP_RELAY + if (!relayUrls.includes(appRelay)) { + relayUrls.push(appRelay) + } + + // Connect to all specified relays + const relayPromises = relayUrls.map((relayUrl) => + this.connectRelay(relayUrl) + ) + + // Use Promise.allSettled to wait for all promises to settle + const results = await Promise.allSettled(relayPromises) + + // Extract non-null values from fulfilled promises in a single pass + const relays = results.reduce((acc, result) => { + if (result.status === 'fulfilled') { + const value = result.value + if (value) { + acc.push(value) + } + } + return acc + }, []) + + let accumulatedZapAmount = 0 + let hasZapped = false + + const eventIds = new Set() // To keep track of event IDs and avoid duplicates + + const filters: Filter[] = [ + { + kinds: [kinds.Zap], + '#e': [eTag] + } + ] + + if (aTag) { + filters.push({ + kinds: [kinds.Zap], + '#a': [aTag] + }) + } + + // Create a promise for each relay subscription + const subPromises = relays.map((relay) => { + return new Promise((resolve) => { + // Subscribe to the relay with the specified filter + const sub = relay.subscribe(filters, { + // 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 + + const zapRequestStr = e.tags.find( + (t) => t[0] === 'description' + )?.[1] + if (!zapRequestStr) return + + const error = nip57.validateZapRequest(zapRequestStr) + if (error) return + + let zapRequest: Event | null = null + + try { + zapRequest = JSON.parse(zapRequestStr) + } catch (error) { + log( + true, + LogType.Error, + 'Error occurred in parsing zap request', + error + ) + } + + if (!zapRequest) return + + const amount = extractZapAmount(zapRequest) + accumulatedZapAmount += amount + + if (amount > 0) { + if (!hasZapped) { + hasZapped = zapRequest.pubkey === currentLoggedInUser + } + } + } + }, + // Handle the End-Of-Stream (EOSE) message + oneose: () => { + sub.close() // Close the subscription + resolve() // Resolve the promise when EOSE is received + } + }) + }) + }) + + // Wait for all subscriptions to complete + await Promise.allSettled(subPromises) + + return { + accumulatedZapAmount, + hasZapped + } + } +} diff --git a/src/controllers/zap.ts b/src/controllers/zap.ts index 0ff300a..8b74dd7 100644 --- a/src/controllers/zap.ts +++ b/src/controllers/zap.ts @@ -17,6 +17,7 @@ import { ZapRequest } from '../types' import { log, LogType, npubToHex } from '../utils' +import { MetadataController, UserRelaysType } from './metadata' /** * Singleton class to manage zap related operations. @@ -47,7 +48,6 @@ export class ZapController { * @param lud16 - LUD-16 of the recipient. * @param amount - payment amount (will be multiplied by 1000 to represent sats). * @param recipientPubKey - pubKey of the recipient. - * @param recipientRelays - relays on which zap receipt will be published. * @param senderPubkey - pubKey of of the sender. * @param content - optional content (comment). * @param eventId - event id, if zapping an event. @@ -59,7 +59,6 @@ export class ZapController { lud16: string, amount: number, recipientPubKey: string, - recipientRelays: string[], senderPubkey: string, content?: string, eventId?: string, @@ -89,7 +88,6 @@ export class ZapController { amount, content, recipientPubKey, - recipientRelays, senderPubkey, eventId, aTag @@ -275,7 +273,6 @@ export class ZapController { * @param amount - request amount (sats). * @param content - comment. * @param recipientPubKey - pubKey of the recipient. - * @param recipientRelays - relays on which zap receipt will be published. * @param senderPubkey - pubKey of of the sender. * @param eventId - event id, if zapping an event. * @param aTag - value of `a` tag. @@ -285,7 +282,6 @@ export class ZapController { amount: number, content = '', recipientPubKey: string, - recipientRelays: string[], senderPubkey: string, eventId?: string, aTag?: string @@ -294,15 +290,21 @@ export class ZapController { if (!recipientHexKey) throw 'Invalid recipient pubKey.' - if (!recipientRelays.includes(this.appRelay)) { - recipientRelays.push(this.appRelay) + const metadataController = await MetadataController.getInstance() + const receiverReadRelays = await metadataController.findUserRelays( + recipientHexKey, + UserRelaysType.Read + ) + + if (!receiverReadRelays.includes(this.appRelay)) { + receiverReadRelays.push(this.appRelay) } const zapRequest: ZapRequest = { kind: kinds.ZapRequest, content, tags: [ - ['relays', ...recipientRelays], + ['relays', ...receiverReadRelays], ['amount', `${amount}`], ['p', recipientHexKey] ], diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 2148b14..2f5a15e 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -6,4 +6,3 @@ export * from './useMuteLists' export * from './useNSFWList' export * from './useReactions' export * from './useNDKContext' -export * from './useScrollDisable' diff --git a/src/hooks/useComments.ts b/src/hooks/useComments.ts index 6be5932..5dd120a 100644 --- a/src/hooks/useComments.ts +++ b/src/hooks/useComments.ts @@ -6,8 +6,9 @@ import { NDKSubscription, NDKSubscriptionCacheUsage } from '@nostr-dev-kit/ndk' +import { UserRelaysType } from 'controllers' import { useEffect, useState } from 'react' -import { CommentEvent, ModDetails, UserRelaysType } from 'types' +import { CommentEvent, ModDetails } from 'types' import { log, LogType } from 'utils' import { useNDKContext } from './useNDKContext' diff --git a/src/hooks/useMuteLists.ts b/src/hooks/useMuteLists.ts index 803da9e..558bcb7 100644 --- a/src/hooks/useMuteLists.ts +++ b/src/hooks/useMuteLists.ts @@ -1,10 +1,9 @@ import { useEffect, useState } from 'react' import { MuteLists } from 'types' import { useAppSelector } from './redux' -import { useNDKContext } from './useNDKContext' +import { MetadataController } from 'controllers' export const useMuteLists = () => { - const { getMuteLists } = useNDKContext() const [muteLists, setMuteLists] = useState<{ admin: MuteLists user: MuteLists @@ -22,11 +21,17 @@ export const useMuteLists = () => { const userState = useAppSelector((state) => state.user) useEffect(() => { - const pubkey = userState.user?.pubkey as string | undefined - getMuteLists(pubkey).then((lists) => { - setMuteLists(lists) - }) - }, [userState, getMuteLists]) + const getMuteLists = async () => { + const pubkey = userState.user?.pubkey as string | undefined + + const metadataController = await MetadataController.getInstance() + metadataController.getMuteLists(pubkey).then((lists) => { + setMuteLists(lists) + }) + } + + getMuteLists() + }, [userState]) return muteLists } diff --git a/src/hooks/useNDKContext.ts b/src/hooks/useNDKContext.ts index f7383df..b551d5e 100644 --- a/src/hooks/useNDKContext.ts +++ b/src/hooks/useNDKContext.ts @@ -9,5 +9,23 @@ export const useNDKContext = () => { 'NDKContext should not be used in out component tree hierarchy' ) - return { ...ndkContext } + const { + ndk, + fetchEvents, + fetchEvent, + fetchEventsFromUserRelays, + fetchEventFromUserRelays, + fetchMods, + findMetadata + } = ndkContext + + return { + ndk, + fetchEvents, + fetchEvent, + fetchEventsFromUserRelays, + fetchEventFromUserRelays, + fetchMods, + findMetadata + } } diff --git a/src/hooks/useNSFWList.ts b/src/hooks/useNSFWList.ts index 9da98db..0712da6 100644 --- a/src/hooks/useNSFWList.ts +++ b/src/hooks/useNSFWList.ts @@ -1,13 +1,14 @@ +import { MetadataController } from 'controllers' import { useState } from 'react' import { useDidMount } from './useDidMount' -import { useNDKContext } from './useNDKContext' export const useNSFWList = () => { - const { getNSFWList } = useNDKContext() const [nsfwList, setNSFWList] = useState([]) useDidMount(async () => { - getNSFWList().then((list) => { + const metadataController = await MetadataController.getInstance() + + metadataController.getNSFWList().then((list) => { setNSFWList(list) }) }) diff --git a/src/hooks/useReactions.ts b/src/hooks/useReactions.ts index 574c3eb..9ebc63f 100644 --- a/src/hooks/useReactions.ts +++ b/src/hooks/useReactions.ts @@ -1,10 +1,10 @@ import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk' import { REACTIONS } from 'constants.ts' +import { RelayController, UserRelaysType } from 'controllers' import { useAppSelector, useDidMount, useNDKContext } from 'hooks' import { Event, kinds, UnsignedEvent } from 'nostr-tools' import { useMemo, useState } from 'react' import { toast } from 'react-toastify' -import { UserRelaysType } from 'types' import { abbreviateNumber, log, LogType, now } from 'utils' type UseReactionsParams = { @@ -14,7 +14,7 @@ type UseReactionsParams = { } export const useReactions = (params: UseReactionsParams) => { - const { ndk, fetchEventsFromUserRelays, publish } = useNDKContext() + const { ndk, fetchEventsFromUserRelays } = useNDKContext() const [isReactionInProgress, setIsReactionInProgress] = useState(false) const [isDataLoaded, setIsDataLoaded] = useState(false) const [reactionEvents, setReactionEvents] = useState([]) @@ -119,11 +119,13 @@ export const useReactions = (params: UseReactionsParams) => { if (!signedEvent) return - const ndkEvent = new NDKEvent(ndk, signedEvent) + setReactionEvents((prev) => [...prev, new NDKEvent(ndk, signedEvent)]) - setReactionEvents((prev) => [...prev, ndkEvent]) - - const publishedOnRelays = await publish(ndkEvent) + const publishedOnRelays = await RelayController.getInstance().publish( + signedEvent as Event, + params.pubkey, + UserRelaysType.Read + ) if (publishedOnRelays.length === 0) { log( diff --git a/src/hooks/useScrollDisable.ts b/src/hooks/useScrollDisable.ts deleted file mode 100644 index d9658a5..0000000 --- a/src/hooks/useScrollDisable.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useEffect } from 'react' - -export const useBodyScrollDisable = (disable: boolean) => { - useEffect(() => { - if (disable) document.body.style.overflow = 'hidden' - - return () => { - document.body.style.overflow = '' - } - }, [disable]) -} diff --git a/src/layout/header.tsx b/src/layout/header.tsx index 011b7cf..bc4c2b8 100644 --- a/src/layout/header.tsx +++ b/src/layout/header.tsx @@ -6,10 +6,10 @@ import React, { useEffect, useState } from 'react' import { Link } from 'react-router-dom' import { Banner } from '../components/Banner' import { ZapPopUp } from '../components/Zap' +import { MetadataController } from '../controllers' import { useAppDispatch, useAppSelector, - useBodyScrollDisable, useDidMount, useNDKContext } from '../hooks' @@ -27,18 +27,6 @@ export const Header = () => { const { findMetadata } = useNDKContext() const userState = useAppSelector((state) => state.user) - // Track nostr-login extension modal open state - const [isOpen, setIsOpen] = useState(false) - const handleOpen = () => setIsOpen(true) - const handleClose = () => setIsOpen(false) - useEffect(() => { - window.addEventListener('nlCloseModal', handleClose) - return () => { - window.removeEventListener('nlCloseModal', handleClose) - } - }, []) - useBodyScrollDisable(isOpen) - useEffect(() => { initNostrLogin({ darkMode: true, @@ -78,7 +66,6 @@ export const Header = () => { }, [dispatch, findMetadata]) const handleLogin = () => { - handleOpen() launchNostrLoginDialog() } @@ -273,11 +260,9 @@ const TipButtonWithDialog = React.memo(() => { const [adminNpub, setAdminNpub] = useState(null) const [isOpen, setIsOpen] = useState(false) - useBodyScrollDisable(isOpen) - useDidMount(async () => { - const adminNpubs = import.meta.env.VITE_ADMIN_NPUBS.split(',') - setAdminNpub(adminNpubs[0]) + const metadataController = await MetadataController.getInstance() + setAdminNpub(metadataController.adminNpubs[0]) }) return ( @@ -336,8 +321,6 @@ const TipButtonWithDialog = React.memo(() => { const RegisterButtonWithDialog = () => { const [showPopUp, setShowPopUp] = useState(false) - useBodyScrollDisable(showPopUp) - return ( <> { - const scrollTargetRef = useRef(null) const params = useParams() const { name: gameName } = params const { ndk } = useNDKContext() @@ -62,7 +61,6 @@ export const GamePage = () => { const handlePageChange = (page: number) => { if (page >= 1 && page <= totalPages) { - scrollIntoView(scrollTargetRef.current) setCurrentPage(page) } } @@ -104,10 +102,7 @@ export const GamePage = () => { <>
-
+
diff --git a/src/pages/games.tsx b/src/pages/games.tsx index 08d576e..e8b8af5 100644 --- a/src/pages/games.tsx +++ b/src/pages/games.tsx @@ -8,10 +8,8 @@ import '../styles/search.css' import '../styles/styles.css' import { createSearchParams, useNavigate } from 'react-router-dom' import { appRoutes } from 'routes' -import { scrollIntoView } from 'utils' export const GamesPage = () => { - const scrollTargetRef = useRef(null) const navigate = useNavigate() const { fetchMods } = useNDKContext() const searchTermRef = useRef(null) @@ -65,7 +63,6 @@ export const GamesPage = () => { const handlePageChange = (page: number) => { if (page >= 1 && page <= totalPages) { - scrollIntoView(scrollTargetRef.current) setCurrentPage(page) } } @@ -91,10 +88,7 @@ export const GamesPage = () => { return (
-
+
diff --git a/src/pages/home.tsx b/src/pages/home.tsx index 5c15e25..79e5a15 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -152,7 +152,7 @@ const SlideContent = ({ naddr }: SlideContentProps) => { useDidMount(() => { const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`) - const { identifier, kind, pubkey } = decoded.data + const { identifier, kind, pubkey, relays = [] } = decoded.data const ndkFilter: NDKFilter = { '#a': [identifier], @@ -160,7 +160,7 @@ const SlideContent = ({ naddr }: SlideContentProps) => { kinds: [kind] } - fetchEvent(ndkFilter) + fetchEvent(ndkFilter, relays) .then((ndkEvent) => { if (ndkEvent) { const extracted = extractModData(ndkEvent) @@ -225,7 +225,7 @@ const DisplayMod = ({ naddr }: DisplayModProps) => { useDidMount(() => { const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`) - const { identifier, kind, pubkey } = decoded.data + const { identifier, kind, pubkey, relays = [] } = decoded.data const ndkFilter: NDKFilter = { '#a': [identifier], @@ -233,7 +233,7 @@ const DisplayMod = ({ naddr }: DisplayModProps) => { kinds: [kind] } - fetchEvent(ndkFilter) + fetchEvent(ndkFilter, relays) .then((ndkEvent) => { if (ndkEvent) { const extracted = extractModData(ndkEvent) diff --git a/src/pages/mod/index.tsx b/src/pages/mod/index.tsx index 8a39cc4..a85d716 100644 --- a/src/pages/mod/index.tsx +++ b/src/pages/mod/index.tsx @@ -11,12 +11,8 @@ import { toast } from 'react-toastify' import { BlogCard } from '../../components/BlogCard' import { LoadingSpinner } from '../../components/LoadingSpinner' import { ProfileSection } from '../../components/ProfileSection' -import { - useAppSelector, - useBodyScrollDisable, - useDidMount, - useNDKContext -} from '../../hooks' +import { MetadataController, UserRelaysType } from '../../controllers' +import { useAppSelector, useDidMount, useNDKContext } from '../../hooks' import { getGamePageRoute, getModsEditPageRoute } from '../../routes' import '../../styles/comments.css' import '../../styles/downloads.css' @@ -28,7 +24,7 @@ import '../../styles/styles.css' import '../../styles/tabs.css' import '../../styles/tags.css' import '../../styles/write.css' -import { DownloadUrl, ModDetails, UserRelaysType } from '../../types' +import { DownloadUrl, ModDetails } from '../../types' import { abbreviateNumber, copyTextToClipboard, @@ -57,7 +53,7 @@ export const ModPage = () => { useDidMount(async () => { if (naddr) { const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`) - const { identifier, kind, pubkey } = decoded.data + const { identifier, kind, pubkey, relays = [] } = decoded.data const filter: NDKFilter = { '#a': [identifier], @@ -65,7 +61,7 @@ export const ModPage = () => { kinds: [kind] } - fetchEvent(filter) + fetchEvent(filter, relays) .then((event) => { if (event) { const extracted = extractModData(event) @@ -216,7 +212,7 @@ type GameProps = { } const Game = ({ naddr, game, author, aTag }: GameProps) => { - const { ndk, fetchEventFromUserRelays, publish } = useNDKContext() + const { fetchEventFromUserRelays } = useNDKContext() const userState = useAppSelector((state) => state.user) const [isLoading, setIsLoading] = useState(false) @@ -225,8 +221,6 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => { const [isBlocked, setIsBlocked] = useState(false) const [isAddedToNSFW, setIsAddedToNSFW] = useState(false) - useBodyScrollDisable(showReportPopUp) - useEffect(() => { if (userState.auth && userState.user?.pubkey) { const pubkey = userState.user.pubkey as string @@ -349,7 +343,7 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => { setLoadingSpinnerDesc('Updating mute list event') - const isUpdated = await signAndPublish(unsignedEvent, ndk, publish) + const isUpdated = await signAndPublish(unsignedEvent) if (isUpdated) { setIsBlocked(true) } @@ -390,7 +384,7 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => { } setLoadingSpinnerDesc('Updating mute list event') - const isUpdated = await signAndPublish(unsignedEvent, ndk, publish) + const isUpdated = await signAndPublish(unsignedEvent) if (isUpdated) { setIsBlocked(false) } @@ -456,7 +450,7 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => { setLoadingSpinnerDesc('Updating nsfw list event') - const isUpdated = await signAndPublish(unsignedEvent, ndk, publish) + const isUpdated = await signAndPublish(unsignedEvent) if (isUpdated) { setIsAddedToNSFW(true) } @@ -497,7 +491,7 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => { } setLoadingSpinnerDesc('Updating nsfw list event') - const isUpdated = await signAndPublish(unsignedEvent, ndk, publish) + const isUpdated = await signAndPublish(unsignedEvent) if (isUpdated) { setIsAddedToNSFW(false) } @@ -667,7 +661,7 @@ type ReportPopupProps = { } const ReportPopup = ({ aTag, handleClose }: ReportPopupProps) => { - const { ndk, fetchEventFromUserRelays, publish } = useNDKContext() + const { fetchEventFromUserRelays } = useNDKContext() const userState = useAppSelector((state) => state.user) const [selectedOptions, setSelectedOptions] = useState({ actuallyCP: false, @@ -714,8 +708,8 @@ const ReportPopup = ({ aTag, handleClose }: ReportPopupProps) => { return } - const reportingNpub = import.meta.env.VITE_REPORTING_NPUB - const reportingPubkey = npubToHex(reportingNpub) + const metadataController = await MetadataController.getInstance() + const reportingPubkey = npubToHex(metadataController.reportingNpub) if (reportingPubkey === hexPubkey) { setLoadingSpinnerDesc(`Finding user's mute list`) @@ -766,7 +760,7 @@ const ReportPopup = ({ aTag, handleClose }: ReportPopupProps) => { } setLoadingSpinnerDesc('Updating mute list event') - const isUpdated = await signAndPublish(unsignedEvent, ndk, publish) + const isUpdated = await signAndPublish(unsignedEvent) if (isUpdated) handleClose() } else { const href = window.location.href @@ -779,12 +773,7 @@ const ReportPopup = ({ aTag, handleClose }: ReportPopupProps) => { }) setLoadingSpinnerDesc('Sending report') - const isSent = await sendDMUsingRandomKey( - message, - reportingPubkey!, - ndk, - publish - ) + const isSent = await sendDMUsingRandomKey(message, reportingPubkey!) if (isSent) handleClose() } setIsLoading(false) diff --git a/src/pages/mod/internal/comment/index.tsx b/src/pages/mod/internal/comment/index.tsx index f238524..2f5b721 100644 --- a/src/pages/mod/internal/comment/index.tsx +++ b/src/pages/mod/internal/comment/index.tsx @@ -1,13 +1,11 @@ -import { NDKEvent } from '@nostr-dev-kit/ndk' import { ZapPopUp } from 'components/Zap' -import { formatDate } from 'date-fns' import { - useAppSelector, - useBodyScrollDisable, - useDidMount, - useNDKContext, - useReactions -} from 'hooks' + MetadataController, + RelayController, + UserRelaysType +} from 'controllers' +import { formatDate } from 'date-fns' +import { useAppSelector, useDidMount, useNDKContext, useReactions } from 'hooks' import { useComments } from 'hooks/useComments' import { Event, kinds, nip19, UnsignedEvent } from 'nostr-tools' import React, { @@ -49,7 +47,6 @@ type Props = { } export const Comments = ({ modDetails, setCommentCount }: Props) => { - const { ndk, publish } = useNDKContext() const { commentEvents, setCommentEvents } = useComments(modDetails) const [filterOptions, setFilterOptions] = useState({ sort: SortByEnum.Latest, @@ -85,8 +82,7 @@ export const Comments = ({ modDetails, setCommentCount }: Props) => { created_at: now(), tags: [ ['e', modDetails.id], - ['a', modDetails.aTag], - ['p', modDetails.author] + ['a', modDetails.aTag] ] } @@ -109,52 +105,28 @@ export const Comments = ({ modDetails, setCommentCount }: Props) => { ...prev ]) - const ndkEvent = new NDKEvent(ndk, signedEvent) - publish(ndkEvent) - .then((publishedOnRelays) => { - if (publishedOnRelays.length === 0) { - setCommentEvents((prev) => - prev.map((event) => { - if (event.id === signedEvent.id) { - return { - ...event, - status: CommentEventStatus.Failed - } - } + const publish = async () => { + const metadataController = await MetadataController.getInstance() + const modAuthorReadRelays = await metadataController.findUserRelays( + modDetails.author, + UserRelaysType.Read + ) + const commentatorWriteRelays = await metadataController.findUserRelays( + pubkey, + UserRelaysType.Write + ) - return event - }) - ) - } else { - setCommentEvents((prev) => - prev.map((event) => { - if (event.id === signedEvent.id) { - return { - ...event, - status: CommentEventStatus.Published - } - } + const combinedRelays = [ + ...new Set(...modAuthorReadRelays, ...commentatorWriteRelays) + ] - return event - }) - ) - } + const publishedOnRelays = + await RelayController.getInstance().publishOnRelays( + signedEvent, + combinedRelays + ) - // when an event is successfully published remove the status from it after 15 seconds - setTimeout(() => { - setCommentEvents((prev) => - prev.map((event) => { - if (event.id === signedEvent.id) { - delete event.status - } - - return event - }) - ) - }, 15000) - }) - .catch((err) => { - console.error('An error occurred in publishing comment', err) + if (publishedOnRelays.length === 0) { setCommentEvents((prev) => prev.map((event) => { if (event.id === signedEvent.id) { @@ -167,7 +139,36 @@ export const Comments = ({ modDetails, setCommentCount }: Props) => { return event }) ) - }) + } else { + setCommentEvents((prev) => + prev.map((event) => { + if (event.id === signedEvent.id) { + return { + ...event, + status: CommentEventStatus.Published + } + } + + return event + }) + ) + } + + // when an event is successfully published remove the status from it after 15 seconds + setTimeout(() => { + setCommentEvents((prev) => + prev.map((event) => { + if (event.id === signedEvent.id) { + delete event.status + } + + return event + }) + ) + }, 15000) + } + + publish() return true } @@ -495,21 +496,20 @@ const Reactions = (props: Event) => { const Zap = (props: Event) => { const [isOpen, setIsOpen] = useState(false) - const [totalZappedAmount, setTotalZappedAmount] = useState(0) const [hasZapped, setHasZapped] = useState(false) const userState = useAppSelector((state) => state.user) - const { getTotalZapAmount } = useNDKContext() - useBodyScrollDisable(isOpen) + const [totalZappedAmount, setTotalZappedAmount] = useState(0) useDidMount(() => { - getTotalZapAmount( - props.pubkey, - props.id, - undefined, - userState.user?.pubkey as string - ) + RelayController.getInstance() + .getTotalZapAmount( + props.pubkey, + props.id, + undefined, + userState.user?.pubkey as string + ) .then((res) => { setTotalZappedAmount(res.accumulatedZapAmount) setHasZapped(res.hasZapped) diff --git a/src/pages/mod/internal/zap/index.tsx b/src/pages/mod/internal/zap/index.tsx index 996c8d2..93e8bad 100644 --- a/src/pages/mod/internal/zap/index.tsx +++ b/src/pages/mod/internal/zap/index.tsx @@ -1,10 +1,6 @@ import { ZapSplit } from 'components/Zap' -import { - useAppSelector, - useBodyScrollDisable, - useDidMount, - useNDKContext -} from 'hooks' +import { RelayController } from 'controllers' +import { useAppSelector, useDidMount } from 'hooks' import { useState } from 'react' import { toast } from 'react-toastify' import { ModDetails } from 'types' @@ -16,21 +12,20 @@ type ZapProps = { export const Zap = ({ modDetails }: ZapProps) => { const [isOpen, setIsOpen] = useState(false) - const [totalZappedAmount, setTotalZappedAmount] = useState(0) const [hasZapped, setHasZapped] = useState(false) const userState = useAppSelector((state) => state.user) - const { getTotalZapAmount } = useNDKContext() - useBodyScrollDisable(isOpen) + const [totalZappedAmount, setTotalZappedAmount] = useState(0) useDidMount(() => { - getTotalZapAmount( - modDetails.author, - modDetails.id, - modDetails.aTag, - userState.user?.pubkey as string - ) + RelayController.getInstance() + .getTotalZapAmount( + modDetails.author, + modDetails.id, + modDetails.aTag, + userState.user?.pubkey as string + ) .then((res) => { setTotalZappedAmount(res.accumulatedZapAmount) setHasZapped(res.hasZapped) diff --git a/src/pages/mods.tsx b/src/pages/mods.tsx index bc217aa..444eb08 100644 --- a/src/pages/mods.tsx +++ b/src/pages/mods.tsx @@ -24,10 +24,8 @@ import { NSFWFilter, SortBy } from '../types' -import { scrollIntoView } from 'utils' export const ModsPage = () => { - const scrollTargetRef = useRef(null) const { fetchMods } = useNDKContext() const [isFetching, setIsFetching] = useState(false) const [mods, setMods] = useState([]) @@ -68,7 +66,6 @@ export const ModsPage = () => { .then((res) => { setMods(res) setPage((prev) => prev + 1) - scrollIntoView(scrollTargetRef.current) }) .finally(() => { setIsFetching(false) @@ -87,7 +84,6 @@ export const ModsPage = () => { .then((res) => { setMods(res) setPage((prev) => prev - 1) - scrollIntoView(scrollTargetRef.current) }) .finally(() => { setIsFetching(false) @@ -107,10 +103,7 @@ export const ModsPage = () => { {isFetching && }
-
+
{ - const scrollTargetRef = useRef(null) const [searchParams] = useSearchParams() const muteLists = useMuteLists() @@ -95,10 +88,7 @@ export const SearchPage = () => { return (
-
+
@@ -151,7 +141,6 @@ export const SearchPage = () => { filterOptions={filterOptions} muteLists={muteLists} nsfwList={nsfwList} - el={scrollTargetRef.current} /> )} {searchKind === SearchKindEnum.Users && ( @@ -274,15 +263,13 @@ type ModsResultProps = { user: MuteLists } nsfwList: string[] - el: HTMLElement | null } const ModsResult = ({ filterOptions, searchTerm, muteLists, - nsfwList, - el + nsfwList }: ModsResultProps) => { const { ndk } = useNDKContext() const [mods, setMods] = useState([]) @@ -318,9 +305,7 @@ const ModsResult = ({ }, [ndk]) useEffect(() => { - scrollIntoView(el) setPage(1) - // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchTerm]) const filteredMods = useMemo(() => { @@ -349,12 +334,10 @@ const ModsResult = ({ ) const handleNext = () => { - scrollIntoView(el) setPage((prev) => prev + 1) } const handlePrev = () => { - scrollIntoView(el) setPage((prev) => prev - 1) } @@ -409,7 +392,7 @@ const UsersResult = ({ } setIsFetching(true) - fetchEvents(filter) + fetchEvents(filter, ['wss://purplepag.es', 'wss://user.kindpag.es']) .then((events) => { const results = events.map((event) => { const ndkEvent = new NDKEvent(undefined, event) diff --git a/src/pages/settings/index.tsx b/src/pages/settings/index.tsx index 4c0f618..60aa2c8 100644 --- a/src/pages/settings/index.tsx +++ b/src/pages/settings/index.tsx @@ -1,4 +1,5 @@ import { AdminSVG, PreferenceSVG, ProfileSVG, RelaySVG } from 'components/SVGs' +import { MetadataController } from 'controllers' import { useAppSelector } from 'hooks' import { logout } from 'nostr-login' import { useEffect, useState } from 'react' @@ -56,12 +57,15 @@ const SettingTabs = () => { const userState = useAppSelector((state) => state.user) useEffect(() => { - const adminNpubs = import.meta.env.VITE_ADMIN_NPUBS.split(',') - if (userState.auth && userState.user?.npub) { - setIsAdmin(adminNpubs.includes(userState.user.npub as string)) - } else { - setIsAdmin(false) - } + MetadataController.getInstance().then((controller) => { + if (userState.auth && userState.user?.npub) { + setIsAdmin( + controller.adminNpubs.includes(userState.user.npub as string) + ) + } else { + setIsAdmin(false) + } + }) }, [userState]) const handleSignOut = () => { diff --git a/src/pages/settings/profile.tsx b/src/pages/settings/profile.tsx index 907d0f0..52f69f7 100644 --- a/src/pages/settings/profile.tsx +++ b/src/pages/settings/profile.tsx @@ -1,6 +1,6 @@ import { InputField } from 'components/Inputs' import { ProfileQRButtonWithPopUp } from 'components/ProfileSection' -import { useAppDispatch, useAppSelector, useNDKContext } from 'hooks' +import { useAppDispatch, useAppSelector } from 'hooks' import { kinds, nip19, UnsignedEvent, Event } from 'nostr-tools' import { useEffect, useState } from 'react' import { Link } from 'react-router-dom' @@ -14,6 +14,7 @@ import { profileFromEvent, serializeProfile } from '@nostr-dev-kit/ndk' +import { RelayController } from 'controllers' import { LoadingSpinner } from 'components/LoadingSpinner' import { setUser } from 'store/reducers/user' import placeholderMod from '../../assets/img/DEGMods Placeholder Img.png' @@ -42,7 +43,6 @@ const defaultFormState: FormState = { export const ProfileSettings = () => { const dispatch = useAppDispatch() const userState = useAppSelector((state) => state.user) - const { ndk, publish } = useNDKContext() const [isPublishing, setIsPublishing] = useState(false) const [formState, setFormState] = useState(defaultFormState) @@ -163,8 +163,9 @@ export const ProfileSettings = () => { return } - const ndkEvent = new NDKEvent(ndk, signedEvent) - const publishedOnRelays = await publish(ndkEvent) + const publishedOnRelays = await RelayController.getInstance().publish( + signedEvent as Event + ) // Handle cases where publishing failed or succeeded if (publishedOnRelays.length === 0) { diff --git a/src/pages/settings/relay.tsx b/src/pages/settings/relay.tsx index 1a32d28..ee79d94 100644 --- a/src/pages/settings/relay.tsx +++ b/src/pages/settings/relay.tsx @@ -1,23 +1,21 @@ -import { - getRelayListForUser, - NDKEvent, - NDKRelayList, - NDKRelayStatus -} from '@nostr-dev-kit/ndk' +import { NDKRelayList } from '@nostr-dev-kit/ndk' import { InputField } from 'components/Inputs' import { LoadingSpinner } from 'components/LoadingSpinner' -import { useAppSelector, useDidMount, useNDKContext } from 'hooks' +import { + MetadataController, + RelayController, + UserRelaysType +} from 'controllers' +import { useAppSelector, useDidMount } from 'hooks' import { Event, kinds, UnsignedEvent } from 'nostr-tools' import { useEffect, useState } from 'react' import { toast } from 'react-toastify' -import { UserRelaysType } from 'types' import { log, LogType, normalizeWebSocketURL, now } from 'utils' const READ_MARKER = 'read' const WRITE_MARKER = 'write' export const RelaySettings = () => { - const { ndk, publish } = useNDKContext() const userState = useAppSelector((state) => state.user) const [ndkRelayList, setNDKRelayList] = useState(null) const [isPublishing, setIsPublishing] = useState(false) @@ -25,8 +23,10 @@ export const RelaySettings = () => { const [inputValue, setInputValue] = useState('') useEffect(() => { - if (userState.auth && userState.user?.pubkey) { - getRelayListForUser(userState.user.pubkey as string, ndk) + const fetchRelayList = async (pubkey: string) => { + const metadataController = await MetadataController.getInstance() + metadataController + .getNDKRelayList(pubkey) .then((res) => { setNDKRelayList(res) }) @@ -38,10 +38,14 @@ export const RelaySettings = () => { ) setNDKRelayList(null) }) + } + + if (userState.auth && userState.user?.pubkey) { + fetchRelayList(userState.user.pubkey as string) } else { setNDKRelayList(null) } - }, [userState, ndk]) + }, [userState]) const handleAdd = async (relayUrl: string) => { if (!ndkRelayList) return @@ -74,8 +78,11 @@ export const RelaySettings = () => { return } - const ndkEvent = new NDKEvent(ndk, signedEvent) - const publishedOnRelays = await publish(ndkEvent) + const publishedOnRelays = + await RelayController.getInstance().publishOnRelays( + signedEvent, + ndkRelayList.writeRelayUrls + ) // Handle cases where publishing failed or succeeded if (publishedOnRelays.length === 0) { @@ -133,8 +140,11 @@ export const RelaySettings = () => { return } - const ndkEvent = new NDKEvent(ndk, signedEvent) - const publishedOnRelays = await publish(ndkEvent) + const publishedOnRelays = + await RelayController.getInstance().publishOnRelays( + signedEvent, + ndkRelayList.writeRelayUrls + ) // Handle cases where publishing failed or succeeded if (publishedOnRelays.length === 0) { @@ -204,8 +214,11 @@ export const RelaySettings = () => { return } - const ndkEvent = new NDKEvent(ndk, signedEvent) - const publishedOnRelays = await publish(ndkEvent) + const publishedOnRelays = + await RelayController.getInstance().publishOnRelays( + signedEvent, + ndkRelayList.writeRelayUrls + ) // Handle cases where publishing failed or succeeded if (publishedOnRelays.length === 0) { @@ -369,29 +382,17 @@ const RelayListItem = ({ changeRelayType }: RelayItemProps) => { const [isConnected, setIsConnected] = useState(false) - const { ndk } = useNDKContext() useDidMount(() => { - const ndkPool = ndk.pool - - ndkPool.on('relay:connect', (relay) => { - if (relay.url === relayUrl) { - setIsConnected(true) - } - }) - - ndkPool.on('relay:disconnect', (relay) => { - if (relay.url === relayUrl) { - setIsConnected(false) - } - }) - - const relay = ndkPool.relays.get(relayUrl) - if (relay && relay.status >= NDKRelayStatus.CONNECTED) { - setIsConnected(true) - } else { - setIsConnected(false) - } + RelayController.getInstance() + .connectRelay(relayUrl) + .then((relay) => { + if (relay && relay.connected) { + setIsConnected(true) + } else { + setIsConnected(false) + } + }) }) return ( diff --git a/src/pages/submitMod.tsx b/src/pages/submitMod.tsx index ba79874..226b896 100644 --- a/src/pages/submitMod.tsx +++ b/src/pages/submitMod.tsx @@ -29,7 +29,7 @@ export const SubmitModPage = () => { useDidMount(async () => { if (naddr) { const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`) - const { identifier, kind, pubkey } = decoded.data + const { identifier, kind, pubkey, relays = [] } = decoded.data const filter: NDKFilter = { '#a': [identifier], @@ -39,7 +39,7 @@ export const SubmitModPage = () => { setIsFetching(true) - fetchEvent(filter) + fetchEvent(filter, relays) .then((event) => { if (event) { const extracted = extractModData(event) diff --git a/src/styles/cardMod.css b/src/styles/cardMod.css index 3250d41..3028908 100644 --- a/src/styles/cardMod.css +++ b/src/styles/cardMod.css @@ -96,7 +96,6 @@ -webkit-box-orient: vertical; overflow: hidden; -webkit-line-clamp: 2; - line-clamp: 2; font-size: 20px; line-height: 1.25; color: rgba(255, 255, 255, 0.75); @@ -108,7 +107,6 @@ -webkit-box-orient: vertical; overflow: hidden; -webkit-line-clamp: 2; - line-clamp: 2; color: rgba(255, 255, 255, 0.5); font-size: 15px; line-height: 1.5; @@ -121,12 +119,11 @@ justify-content: start; align-items: center; font-size: 14px; - background: rgba(255, 255, 255, 0.05); + background: rgba(255,255,255,0.05); display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; -webkit-line-clamp: 1; - line-clamp: 1; } .cMMFootReactions { @@ -146,12 +143,3 @@ align-items: center; color: rgba(255, 255, 255, 0.25); } - -.IBMSMSMBSSTagsTag.IBMSMSMBSSTagsTagNSFW.IBMSMSMBSSTagsTagNSFWCard { - position: absolute; - bottom: 10px; - right: 10px; - -webkit-backdrop-filter: blur(10px); - backdrop-filter: blur(10px); - background: rgba(35, 35, 35, 0.85); -} diff --git a/src/types/user.ts b/src/types/user.ts index 059ba84..551bd1f 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -1,9 +1,3 @@ import { NDKUserProfile } from '@nostr-dev-kit/ndk' export type UserProfile = NDKUserProfile | null - -export enum UserRelaysType { - Read = 'readRelayUrls', - Write = 'writeRelayUrls', - Both = 'bothRelayUrls' -} diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index 130d023..f3711b0 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -9,8 +9,9 @@ import { UnsignedEvent } from 'nostr-tools' import { toast } from 'react-toastify' +import { RelayController } from '../controllers' import { log, LogType } from './utils' -import NDK, { NDKEvent } from '@nostr-dev-kit/ndk' +import { NDKEvent } from '@nostr-dev-kit/ndk' /** * Get the current time in seconds since the Unix epoch (January 1, 1970). @@ -122,11 +123,7 @@ export const extractZapAmount = (event: Event): number => { * @param unsignedEvent - The event object which needs to be signed before publishing. * @returns - A promise that resolves to boolean indicating whether the event was successfully signed and published */ -export const signAndPublish = async ( - unsignedEvent: UnsignedEvent, - ndk: NDK, - publish: (event: NDKEvent) => Promise -) => { +export const signAndPublish = async (unsignedEvent: UnsignedEvent) => { // Sign the event. This returns a signed event or null if signing fails. const signedEvent = await window.nostr ?.signEvent(unsignedEvent) @@ -141,10 +138,11 @@ export const signAndPublish = async ( // If the event couldn't be signed, exit the function and return null. if (!signedEvent) return false - // Publish the signed event to the relays. + // Publish the signed event to the relays using the RelayController. // This returns an array of relay URLs where the event was successfully published. - const ndkEvent = new NDKEvent(ndk, signedEvent) - const publishedOnRelays = await publish(ndkEvent) + const publishedOnRelays = await RelayController.getInstance().publish( + signedEvent as Event + ) // Handle cases where publishing to the relays failed if (publishedOnRelays.length === 0) { @@ -172,9 +170,7 @@ export const signAndPublish = async ( */ export const sendDMUsingRandomKey = async ( message: string, - receiver: string, - ndk: NDK, - publish: (event: NDKEvent) => Promise + receiver: string ) => { // Generate a random secret key for encrypting the message const secretKey = generateSecretKey() @@ -205,8 +201,11 @@ export const sendDMUsingRandomKey = async ( // Finalize and sign the event using the generated secret key const signedEvent = finalizeEvent(unsignedEvent, secretKey) - const ndkEvent = new NDKEvent(ndk, signedEvent) - const publishedOnRelays = await publish(ndkEvent) + // Publish the signed event (the encrypted DM) to the relays + const publishedOnRelays = await RelayController.getInstance().publishDM( + signedEvent, + receiver + ) // Handle cases where publishing to the relays failed if (publishedOnRelays.length === 0) { diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 6c904e0..b321c91 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -135,14 +135,3 @@ export const handleModImageError = ( ) => { e.currentTarget.src = import.meta.env.VITE_FALLBACK_MOD_IMAGE } - -export const scrollIntoView = (el: HTMLElement | null) => { - if (el) { - setTimeout(() => { - el.scrollIntoView({ - behavior: 'smooth', - block: 'start' - }) - }, 100) - } -}