diff --git a/package-lock.json b/package-lock.json index d871d30..b127ac9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@getalby/lightning-tools": "5.0.3", "@nostr-dev-kit/ndk": "2.10.0", + "@nostr-dev-kit/ndk-cache-dexie": "2.5.1", "@reduxjs/toolkit": "2.2.6", "@tiptap/core": "2.6.6", "@tiptap/extension-link": "2.6.6", @@ -19,6 +20,7 @@ "bech32": "2.0.0", "buffer": "6.0.3", "date-fns": "3.6.0", + "dexie": "4.0.8", "dompurify": "3.1.6", "file-saver": "2.0.5", "fslightbox-react": "1.7.6", @@ -1043,22 +1045,25 @@ } }, "node_modules/@noble/curves": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.4.2.tgz", - "integrity": "sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.6.0.tgz", + "integrity": "sha512-TlaHRXDehJuRNR9TfZDNQ45mMEd5dwUwmicsafcIX4SsNiqnCHKjE/1alYPd/lDRVhxdhUAlv8uEhMCI5zjIJQ==", "dependencies": { - "@noble/hashes": "1.4.0" + "@noble/hashes": "1.5.0" + }, + "engines": { + "node": "^14.21.3 || >=16" }, "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/@noble/hashes": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", - "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.5.0.tgz", + "integrity": "sha512-1j6kQFb7QRru7eKN3ZDvRcP13rugwdxZqCjbiAVZfIJwgj2A65UmT4TgARXGlXgnRkORLTDTrO19ZErt7+QXgA==", "engines": { - "node": ">= 16" + "node": "^14.21.3 || >=16" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -1129,6 +1134,18 @@ "node": ">=16" } }, + "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/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -1383,9 +1400,9 @@ ] }, "node_modules/@scure/base": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.7.tgz", - "integrity": "sha512-PPNYBslrLNNUQ/Yad37MHYsNQtK67EhWb6WtSvNLLPo7SdVZgkUjD6Dg+5On7zNwmskf8OX7I7Nx5oN+MIWE0g==", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz", + "integrity": "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==", "funding": { "url": "https://paulmillr.com/funding/" } @@ -2670,11 +2687,11 @@ } }, "node_modules/debug": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", - "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -2699,6 +2716,11 @@ "node": ">=0.4.0" } }, + "node_modules/dexie": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.0.8.tgz", + "integrity": "sha512-1G6cJevS17KMDK847V3OHvK2zei899GwpDiqfEXHP1ASvme6eWJmAp9AU4s1son2TeGkWmC0g3y8ezOBPnalgQ==" + }, "node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -3718,9 +3740,9 @@ } }, "node_modules/light-bolt11-decoder": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/light-bolt11-decoder/-/light-bolt11-decoder-3.1.1.tgz", - "integrity": "sha512-sLg/KCwYkgsHWkefWd6KqpCHrLFWWaXTOX3cf6yD2hAzL0SLpX+lFcaFK2spkjbgzG6hhijKfORDc9WoUHwX0A==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/light-bolt11-decoder/-/light-bolt11-decoder-3.2.0.tgz", + "integrity": "sha512-3QEofgiBOP4Ehs9BI+RkZdXZNtSys0nsJ6fyGeSiAGCBsMwHGUDS/JQlY/sTnWs91A2Nh0S9XXfA8Sy9g6QpuQ==", "dependencies": { "@scure/base": "1.1.1" } @@ -3884,9 +3906,9 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/nanoid": { "version": "3.3.7", diff --git a/package.json b/package.json index b01cc2e..e1e39e7 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dependencies": { "@getalby/lightning-tools": "5.0.3", "@nostr-dev-kit/ndk": "2.10.0", + "@nostr-dev-kit/ndk-cache-dexie": "2.5.1", "@reduxjs/toolkit": "2.2.6", "@tiptap/core": "2.6.6", "@tiptap/extension-link": "2.6.6", @@ -21,6 +22,7 @@ "bech32": "2.0.0", "buffer": "6.0.3", "date-fns": "3.6.0", + "dexie": "4.0.8", "dompurify": "3.1.6", "file-saver": "2.0.5", "fslightbox-react": "1.7.6", diff --git a/src/components/ProfileSection.tsx b/src/components/ProfileSection.tsx index fa4d6e8..72d1ee9 100644 --- a/src/components/ProfileSection.tsx +++ b/src/components/ProfileSection.tsx @@ -4,12 +4,8 @@ import { QRCodeSVG } from 'qrcode.react' import { useState } from 'react' import { Link } from 'react-router-dom' import { toast } from 'react-toastify' -import { - MetadataController, - RelayController, - UserRelaysType -} from '../controllers' -import { useAppSelector, useDidMount } 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' @@ -31,11 +27,11 @@ type Props = { } export const ProfileSection = ({ pubkey }: Props) => { + const { findMetadata } = useNDKContext() const [profile, setProfile] = useState() - useDidMount(async () => { - const metadataController = await MetadataController.getInstance() - metadataController.findMetadata(pubkey).then((res) => { + useDidMount(() => { + findMetadata(pubkey).then((res) => { setProfile(res) }) }) @@ -371,6 +367,7 @@ type FollowButtonProps = { } const FollowButton = ({ pubkey }: FollowButtonProps) => { + const { fetchEventFromUserRelays } = useNDKContext() const [isFollowing, setIsFollowing] = useState(false) const [isLoading, setIsLoading] = useState(false) @@ -409,12 +406,11 @@ const FollowButton = ({ pubkey }: FollowButtonProps) => { authors: [userHexKey] } - const contactListEvent = - await RelayController.getInstance().fetchEventFromUserRelays( - filter, - userHexKey, - UserRelaysType.Both - ) + const contactListEvent = await fetchEventFromUserRelays( + filter, + userHexKey, + UserRelaysType.Both + ) if (!contactListEvent) return { @@ -513,12 +509,11 @@ const FollowButton = ({ pubkey }: FollowButtonProps) => { authors: [userHexKey] } - const contactListEvent = - await RelayController.getInstance().fetchEventFromUserRelays( - filter, - userHexKey, - UserRelaysType.Both - ) + const contactListEvent = await fetchEventFromUserRelays( + filter, + userHexKey, + UserRelaysType.Both + ) if ( !contactListEvent || diff --git a/src/components/Zap.tsx b/src/components/Zap.tsx index 7273465..e7c7005 100644 --- a/src/components/Zap.tsx +++ b/src/components/Zap.tsx @@ -10,7 +10,7 @@ import React, { import Countdown, { CountdownRenderProps } from 'react-countdown' import { toast } from 'react-toastify' import { MetadataController, ZapController } from '../controllers' -import { useAppSelector, useDidMount } from '../hooks' +import { useAppSelector, useDidMount, useNDKContext } from '../hooks' import '../styles/popup.css' import { PaymentRequest, UserProfile } from '../types' import { @@ -133,9 +133,11 @@ export const ZapQR = React.memo( setTotalZapAmount, setHasZapped }: ZapQRProps) => { + const { ndk } = useNDKContext() + useDidMount(() => { ZapController.getInstance() - .pollZapReceipt(paymentRequest) + .pollZapReceipt(paymentRequest, ndk) .then((zapReceipt) => { toast.success(`Successfully sent sats!`) if (setTotalZapAmount) { @@ -249,6 +251,7 @@ export const ZapPopUp = ({ setHasZapped, handleClose }: ZapPopUpProps) => { + const { findMetadata } = useNDKContext() const [isLoading, setIsLoading] = useState(false) const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') const [amount, setAmount] = useState(0) @@ -282,9 +285,8 @@ export const ZapPopUp = ({ } setLoadingSpinnerDesc('finding receiver metadata') - const metadataController = await MetadataController.getInstance() - const receiverMetadata = await metadataController.findMetadata(receiver) + const receiverMetadata = await findMetadata(receiver) if (!receiverMetadata?.lud16) { setIsLoading(false) @@ -480,6 +482,7 @@ export const ZapSplit = ({ setHasZapped, handleClose }: ZapSplitProps) => { + const { findMetadata } = useNDKContext() const [isLoading, setIsLoading] = useState(false) const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') const [amount, setAmount] = useState(0) @@ -495,12 +498,12 @@ export const ZapSplit = ({ const [invoices, setInvoices] = useState>() useDidMount(async () => { - const metadataController = await MetadataController.getInstance() - metadataController.findMetadata(pubkey).then((res) => { + findMetadata(pubkey).then((res) => { setAuthor(res) }) - metadataController.findAdminMetadata().then((res) => { + const metadataController = await MetadataController.getInstance() + findMetadata(metadataController.adminNpubs[0]).then((res) => { setAdmin(res) }) }) diff --git a/src/contexts/NDKContext.tsx b/src/contexts/NDKContext.tsx new file mode 100644 index 0000000..0ec3771 --- /dev/null +++ b/src/contexts/NDKContext.tsx @@ -0,0 +1,347 @@ +import NDK, { + getRelayListForUser, + NDKEvent, + NDKFilter, + NDKKind, + NDKRelaySet, + NDKSubscriptionCacheUsage, + 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, UserProfile } from 'types' +import { + constructModListFromEvents, + hexToNpub, + log, + LogType, + npubToHex, + orderEventsChronologically +} from 'utils' + +type FetchModsOptions = { + source?: string + until?: number + since?: number + limit?: number +} + +interface NDKContextType { + ndk: NDK + fetchMods: (opts: FetchModsOptions) => Promise + fetchEvents: (filter: NDKFilter, relayUrls?: string[]) => Promise + fetchEvent: ( + filter: NDKFilter, + relayUrls?: string[] + ) => Promise + + fetchEventsFromUserRelays: ( + filter: NDKFilter, + hexKey: string, + userRelaysType: UserRelaysType + ) => Promise + fetchEventFromUserRelays: ( + filter: NDKFilter, + hexKey: string, + userRelaysType: UserRelaysType + ) => Promise + findMetadata: (pubkey: 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() + console.log(event.reason) + 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: [ + 'wss://user.kindpag.es', + 'wss://purplepag.es', + 'wss://relay.damus.io/', + import.meta.env.VITE_APP_RELAY + ], + cacheAdapter: dexieAdapter + }) + + ndk.connect() + + return ndk + }, []) + + /** + * Fetches a list of mods based on the provided source. + * + * @param source - The source URL to filter the mods. If it matches the current window location, + * it adds a filter condition to the request. + * @param until - Optional timestamp to filter events until this time. + * @param since - Optional timestamp to filter events from this time. + * @returns A promise that resolves to an array of `ModDetails` objects. In case of an error, + * it logs the error and shows a notification, then returns an empty array. + */ + const fetchMods = async ({ + source, + until, + 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 + limit: limit || MOD_FILTER_LIMIT, // Limit the number of events fetched to 20 + '#t': [T_TAG_VALUE], + until, // Optional filter to fetch events until this timestamp + since // Optional filter to fetch events from this timestamp + } + + // If the source matches the current window location, add a filter condition + if (source === window.location.host) { + filter['#r'] = [window.location.host] // Add a tag filter for the current host + } + + return ndk + .fetchEvents( + filter, + { closeOnEose: true, cacheUsage: NDKSubscriptionCacheUsage.PARALLEL }, + NDKRelaySet.fromRelayUrls(Array.from(relays), ndk, true) + ) + .then((ndkEventSet) => { + const ndkEvents = Array.from(ndkEventSet) + orderEventsChronologically(ndkEvents) + + // Convert the fetched events into a list of mods + const modList = constructModListFromEvents(ndkEvents) + return modList // Return the list of mods + }) + .catch((err) => { + // Log the error and show a notification if fetching fails + log( + true, + LogType.Error, + 'An error occurred in fetching mods from relays', + err + ) + toast.error('An error occurred in fetching mods from relays') // Show error notification + return [] // Return an empty array in case of an error + }) + } + + /** + * 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, + 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 }, + NDKRelaySet.fromRelayUrls(Array.from(relays), 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 + }) + } + + /** + * 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, relayUrls: string[] = []) => { + const events = await fetchEvents(filter, relayUrls) + 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, + hexKey: string, + userRelaysType: UserRelaysType + ) => { + // Find the user's relays. + const relayUrls = await getRelayListForUser(hexKey, ndk) + .then((ndkRelayList) => { + if (ndkRelayList) return ndkRelayList[userRelaysType] + return [] // Return an empty array if ndkRelayList is undefined + }) + .catch((err) => { + log( + true, + LogType.Error, + `An error occurred in fetching user's (${hexKey}) ${userRelaysType}`, + err + ) + return [] as string[] + }) + + // Fetch the event from the user's relays using the provided filter and relay URLs + return fetchEvents(filter, relayUrls) + } + + /** + * 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, + 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 + + const userProfile = await user.fetchProfile() + + return userProfile + } + + return ( + + {children} + + ) +} diff --git a/src/controllers/metadata.ts b/src/controllers/metadata.ts index 6cebdcb..98967d4 100644 --- a/src/controllers/metadata.ts +++ b/src/controllers/metadata.ts @@ -1,8 +1,7 @@ -import NDK, { getRelayListForUser, NDKList, NDKUser } from '@nostr-dev-kit/ndk' +import NDK, { getRelayListForUser, NDKList } from '@nostr-dev-kit/ndk' import { kinds } from 'nostr-tools' import { MuteLists } from '../types' -import { UserProfile } from '../types/user' -import { hexToNpub, log, LogType, npubToHex, timeout } from '../utils' +import { log, LogType, npubToHex, timeout } from '../utils' export enum UserRelaysType { Read = 'readRelayUrls', @@ -16,7 +15,6 @@ export enum UserRelaysType { export class MetadataController { private static instance: MetadataController private ndk: NDK - private usersMetadata = new Map() public adminNpubs: string[] public adminRelays = new Set() public reportingNpub: string @@ -84,40 +82,6 @@ export class MetadataController { return MetadataController.instance } - /** - * Finds metadata for a given pubkey. - * - * @param hexKey - The pubkey to search for metadata. - * @returns A promise that resolves to the metadata event. - */ - public findMetadata = async (pubkey: string): Promise => { - const npub = hexToNpub(pubkey) - - const cachedMetadata = this.usersMetadata.get(npub) - if (cachedMetadata) { - return cachedMetadata - } - - const user = new NDKUser({ npub }) - user.ndk = this.ndk - - const userProfile = await user.fetchProfile() - if (userProfile) { - this.usersMetadata.set(npub, userProfile) - } - - return userProfile - } - - /** - * Finds metadata for admin user. - * - * @returns A promise that resolves to the metadata event. - */ - public findAdminMetadata = async (): Promise => { - return this.findMetadata(this.adminNpubs[0]) - } - public findUserRelays = async ( hexKey: string, userRelaysType: UserRelaysType = UserRelaysType.Both diff --git a/src/controllers/relay.ts b/src/controllers/relay.ts index bc347bc..2fdd6ae 100644 --- a/src/controllers/relay.ts +++ b/src/controllers/relay.ts @@ -372,217 +372,6 @@ export class RelayController { return publishedOnRelays } - /** - * 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} filter - The filter criteria to find the event. - * @param {string[]} [relays] - An optional array of relay URLs to search for the event. - * @returns {Promise} - Returns a promise that resolves to the found event or null if not found. - */ - fetchEvents = async ( - filter: Filter, - relayUrls: string[] = [] - ): Promise => { - const relaySet = new Set() - - // add all the relays passed to relay set - relayUrls.forEach((relayUrl) => { - relaySet.add(relayUrl) - }) - - relaySet.add(import.meta.env.VITE_APP_RELAY) - - const metadataController = await MetadataController.getInstance() - // add admin relays to relays array - metadataController.adminRelays.forEach((relayUrl) => { - relaySet.add(relayUrl) - }) - - relayUrls = Array.from(relaySet) - - // 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 events: Event[] = [] - const eventIds = new Set() // To keep track of event IDs and avoid duplicates - - // Create a promise for each relay subscription - const subPromises = relays.map((relay) => { - return new Promise((resolve) => { - // Subscribe to the relay with the specified filter - const sub = relay.subscribe([filter], { - // Handle incoming events - onevent: (e) => { - // Add the event to the array if it's not a duplicate - if (!eventIds.has(e.id)) { - eventIds.add(e.id) // Record the event ID - events.push(e) // Add the event to the array - } - }, - // Handle the End-Of-Stream (EOSE) message - oneose: () => { - sub.close() // Close the subscription - resolve() // Resolve the promise when EOSE is received - } - }) - }) - }) - - // Wait for all subscriptions to complete - await Promise.allSettled(subPromises) - - // It is possible that different relays will send different events and events array may contain more events then specified limit in filter - // To fix this issue we'll first sort these events and then return only limited events - if (filter.limit) { - // Sort events by creation date in descending order - events.sort((a, b) => b.created_at - a.created_at) - - return events.slice(0, filter.limit) - } - - return events - } - - /** - * Asynchronously retrieves an event from a set of relays based on a provided filter. - * If no relays are specified, it defaults to using connected relays. - * - * @param {Filter} filter - The filter criteria to find the event. - * @param {string[]} [relays] - An optional array of relay URLs to search for the event. - * @returns {Promise} - Returns a promise that resolves to the found event or null if not found. - */ - fetchEvent = async ( - filter: Filter, - relays: string[] = [] - ): Promise => { - // first check if event is present in cached map then return that - // otherwise query relays - if (filter['#a']) { - const aTag = filter['#a'][0] - const cachedEvent = this.events.get(aTag) - - if (cachedEvent) return cachedEvent - } - - const events = await this.fetchEvents(filter, relays) - - // Sort events by creation date in descending order - events.sort((a, b) => b.created_at - a.created_at) - - if (events.length > 0) { - const event = events[0] - - // if the aTag was specified in filter then cache the fetched event before returning - if (filter['#a']) { - const aTag = filter['#a'][0] - this.events.set(aTag, event) - } - - // return the event - return event - } - - // return null if event array is empty - return null - } - - /** - * 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. - */ - fetchEventsFromUserRelays = async ( - filter: Filter, - hexKey: string, - userRelaysType: UserRelaysType - ): Promise => { - // Get an instance of the MetadataController, which manages user metadata and relays - const metadataController = await MetadataController.getInstance() - - // Find the user's relays using the MetadataController. - const relayUrls = await metadataController.findUserRelays( - hexKey, - userRelaysType - ) - - // Fetch the event from the user's relays using the provided filter and relay URLs - return this.fetchEvents(filter, relayUrls) - } - - /** - * 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. - */ - fetchEventFromUserRelays = async ( - filter: Filter, - hexKey: string, - userRelaysType: UserRelaysType - ): Promise => { - // first check if event is present in cached map then return that - // otherwise query relays - if (filter['#a']) { - const aTag = filter['#a'][0] - const cachedEvent = this.events.get(aTag) - - if (cachedEvent) return cachedEvent - } - - const events = await this.fetchEventsFromUserRelays( - filter, - hexKey, - userRelaysType - ) - // Sort events by creation date in descending order - events.sort((a, b) => b.created_at - a.created_at) - - if (events.length > 0) { - const event = events[0] - - // if the aTag was specified in filter then cache the fetched event before returning - if (filter['#a']) { - const aTag = filter['#a'][0] - this.events.set(aTag, event) - } - - // return the event - return event - } - - // return null if event array is empty - return null - } - /** * Subscribes to events from multiple relays. * diff --git a/src/controllers/zap.ts b/src/controllers/zap.ts index 5bbaf51..8b74dd7 100644 --- a/src/controllers/zap.ts +++ b/src/controllers/zap.ts @@ -1,6 +1,12 @@ import { Invoice } from '@getalby/lightning-tools' +import NDK, { + NDKFilter, + NDKKind, + NDKRelaySet, + NDKSubscriptionCacheUsage +} from '@nostr-dev-kit/ndk' import axios, { AxiosInstance } from 'axios' -import { Filter, kinds } from 'nostr-tools' +import { kinds } from 'nostr-tools' import { requestProvider, SendPaymentResponse, WebLNProvider } from 'webln' import { isLnurlResponse, @@ -11,7 +17,6 @@ import { ZapRequest } from '../types' import { log, LogType, npubToHex } from '../utils' -import { RelayController } from './relay' import { MetadataController, UserRelaysType } from './metadata' /** @@ -134,6 +139,7 @@ export class ZapController { */ async pollZapReceipt( paymentRequest: PaymentRequest, + ndk: NDK, pollingTimeout?: number ) { const { pr, ...zapRequest } = paymentRequest @@ -148,7 +154,7 @@ export class ZapController { const cleanup = () => { clearTimeout(timeout) - subscriptions.forEach((subscription) => subscription.close()) + subscription.stop() } // Polling timeout @@ -168,32 +174,35 @@ export class ZapController { const relayUrls = relaysTag.slice(1) // filter relay for event of kind 9735 - const filter: Filter = { - kinds: [kinds.Zap], + const filter: NDKFilter = { + kinds: [NDKKind.Zap], since: created_at } - const subscriptions = - await RelayController.getInstance().subscribeForEvents( - filter, - relayUrls, - async (event) => { - // get description tag of the event - const description = event.tags.filter( - (tag) => tag[0] === 'description' - )[0] + const subscription = ndk.subscribe( + filter, + { + closeOnEose: false, + cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY + }, + NDKRelaySet.fromRelayUrls(relayUrls, ndk, true) + ) - // compare description tag of the event with stringified zap request - if (description[1] === zapRequestStringified) { - // validate zap receipt - if (await this.validateZapReceipt(pr, event as ZapReceipt)) { - cleanup() + subscription.on('event', async (ndkEvent) => { + // compare description tag of the event with stringified zap request + if (ndkEvent.tagValue('description') === zapRequestStringified) { + // validate zap receipt + if ( + await this.validateZapReceipt(pr, ndkEvent.rawEvent() as ZapReceipt) + ) { + cleanup() - resolve(event as ZapReceipt) - } - } + resolve(ndkEvent.rawEvent() as ZapReceipt) } - ) + } + }) + + subscription.start() }) } diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 70dfe7f..2f5a15e 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -5,3 +5,4 @@ export * from './useGames' export * from './useMuteLists' export * from './useNSFWList' export * from './useReactions' +export * from './useNDKContext' diff --git a/src/hooks/useComments.ts b/src/hooks/useComments.ts index 808e67d..5dd120a 100644 --- a/src/hooks/useComments.ts +++ b/src/hooks/useComments.ts @@ -1,43 +1,87 @@ import { - MetadataController, - RelayController, - UserRelaysType -} from 'controllers' -import { Filter, kinds } from 'nostr-tools' -import { useState } from 'react' + getRelayListForUser, + NDKFilter, + NDKKind, + NDKRelaySet, + NDKSubscription, + NDKSubscriptionCacheUsage +} from '@nostr-dev-kit/ndk' +import { UserRelaysType } from 'controllers' +import { useEffect, useState } from 'react' import { CommentEvent, ModDetails } from 'types' -import { useDidMount } from './useDidMount' +import { log, LogType } from 'utils' +import { useNDKContext } from './useNDKContext' export const useComments = (mod: ModDetails) => { + const { ndk } = useNDKContext() const [commentEvents, setCommentEvents] = useState([]) - useDidMount(async () => { - const metadataController = await MetadataController.getInstance() + useEffect(() => { + let subscription: NDKSubscription // Define the subscription variable here for cleanup - const authorReadRelays = await metadataController.findUserRelays( - mod.author, - UserRelaysType.Read - ) + const setupSubscription = async () => { + // Find the mod author's relays. + const authorReadRelays = await getRelayListForUser(mod.author, ndk) + .then((ndkRelayList) => { + if (ndkRelayList) return ndkRelayList[UserRelaysType.Read] + return [] // Return an empty array if ndkRelayList is undefined + }) + .catch((err) => { + log( + true, + LogType.Error, + `An error occurred in fetching user's (${mod.author}) ${UserRelaysType.Read}`, + err + ) + return [] as string[] + }) - const filter: Filter = { - kinds: [kinds.ShortTextNote], - '#a': [mod.aTag] - } + const filter: NDKFilter = { + kinds: [NDKKind.Text], + '#a': [mod.aTag] + } - RelayController.getInstance().subscribeForEvents( - filter, - authorReadRelays, - (event) => { + subscription = ndk.subscribe( + filter, + { + closeOnEose: false, + cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST + }, + NDKRelaySet.fromRelayUrls(authorReadRelays, ndk, true) + ) + + subscription.on('event', (ndkEvent) => { setCommentEvents((prev) => { - if (prev.find((e) => e.id === event.id)) { + if (prev.find((e) => e.id === ndkEvent.id)) { return [...prev] } - return [event, ...prev] + const commentEvent: CommentEvent = { + kind: NDKKind.Text, + tags: ndkEvent.tags, + content: ndkEvent.content, + created_at: ndkEvent.created_at!, + pubkey: ndkEvent.pubkey, + id: ndkEvent.id, + sig: ndkEvent.sig! + } + + return [commentEvent, ...prev] }) + }) + + subscription.start() + } + + setupSubscription() + + // Cleanup function to stop the subscription on unmount + return () => { + if (subscription) { + subscription.stop() } - ) - }) + } + }, [mod.aTag, mod.author, ndk]) return { commentEvents, diff --git a/src/hooks/useNDKContext.ts b/src/hooks/useNDKContext.ts new file mode 100644 index 0000000..b551d5e --- /dev/null +++ b/src/hooks/useNDKContext.ts @@ -0,0 +1,31 @@ +import { NDKContext } 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' + ) + + const { + ndk, + fetchEvents, + fetchEvent, + fetchEventsFromUserRelays, + fetchEventFromUserRelays, + fetchMods, + findMetadata + } = ndkContext + + return { + ndk, + fetchEvents, + fetchEvent, + fetchEventsFromUserRelays, + fetchEventFromUserRelays, + fetchMods, + findMetadata + } +} diff --git a/src/hooks/useReactions.ts b/src/hooks/useReactions.ts index 5ce1716..9ebc63f 100644 --- a/src/hooks/useReactions.ts +++ b/src/hooks/useReactions.ts @@ -1,9 +1,10 @@ -import { useState, useMemo } from 'react' -import { toast } from 'react-toastify' +import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk' import { REACTIONS } from 'constants.ts' import { RelayController, UserRelaysType } from 'controllers' -import { useAppSelector, useDidMount } from 'hooks' -import { Event, Filter, UnsignedEvent, kinds } from 'nostr-tools' +import { useAppSelector, useDidMount, useNDKContext } from 'hooks' +import { Event, kinds, UnsignedEvent } from 'nostr-tools' +import { useMemo, useState } from 'react' +import { toast } from 'react-toastify' import { abbreviateNumber, log, LogType, now } from 'utils' type UseReactionsParams = { @@ -13,14 +14,15 @@ type UseReactionsParams = { } export const useReactions = (params: UseReactionsParams) => { + const { ndk, fetchEventsFromUserRelays } = useNDKContext() const [isReactionInProgress, setIsReactionInProgress] = useState(false) const [isDataLoaded, setIsDataLoaded] = useState(false) - const [reactionEvents, setReactionEvents] = useState([]) + const [reactionEvents, setReactionEvents] = useState([]) const userState = useAppSelector((state) => state.user) useDidMount(() => { - const filter: Filter = { + const filter: NDKFilter = { kinds: [kinds.Reaction] } @@ -30,8 +32,7 @@ export const useReactions = (params: UseReactionsParams) => { filter['#e'] = [params.eTag] } - RelayController.getInstance() - .fetchEventsFromUserRelays(filter, params.pubkey, UserRelaysType.Read) + fetchEventsFromUserRelays(filter, params.pubkey, UserRelaysType.Read) .then((events) => { setReactionEvents(events) }) @@ -118,7 +119,7 @@ export const useReactions = (params: UseReactionsParams) => { if (!signedEvent) return - setReactionEvents((prev) => [...prev, signedEvent]) + setReactionEvents((prev) => [...prev, new NDKEvent(ndk, signedEvent)]) const publishedOnRelays = await RelayController.getInstance().publish( signedEvent as Event, diff --git a/src/layout/header.tsx b/src/layout/header.tsx index 16382a7..049d748 100644 --- a/src/layout/header.tsx +++ b/src/layout/header.tsx @@ -7,7 +7,12 @@ import { Link } from 'react-router-dom' import { Banner } from '../components/Banner' import { ZapPopUp } from '../components/Zap' import { MetadataController } from '../controllers' -import { useAppDispatch, useAppSelector, useDidMount } from '../hooks' +import { + useAppDispatch, + useAppSelector, + useDidMount, + useNDKContext +} from '../hooks' import { appRoutes } from '../routes' import { setAuth, setUser } from '../store/reducers/user' import mainStyles from '../styles//main.module.scss' @@ -17,6 +22,7 @@ import { npubToHex } from '../utils' export const Header = () => { const dispatch = useAppDispatch() + const { findMetadata } = useNDKContext() const userState = useAppSelector((state) => state.user) useEffect(() => { @@ -41,23 +47,21 @@ export const Header = () => { pubkey: npubToHex(npub)! }) ) - MetadataController.getInstance().then((metadataController) => { - metadataController.findMetadata(npub).then((userProfile) => { - if (userProfile) { - dispatch( - setUser({ - npub, - pubkey: npubToHex(npub)!, - ...userProfile - }) - ) - } - }) + findMetadata(npub).then((userProfile) => { + if (userProfile) { + dispatch( + setUser({ + npub, + pubkey: npubToHex(npub)!, + ...userProfile + }) + ) + } }) } } }) - }, [dispatch]) + }, [dispatch, findMetadata]) const handleLogin = () => { launchNostrLoginDialog() @@ -357,8 +361,14 @@ const RegisterButtonWithDialog = () => {

Once you create your "account" on any of these ( - Here's a quick video guide - ), come back and click login, then sign-in with extension. + + Here's a quick video guide + + ), come back and click login, then sign-in with + extension.

- + + + diff --git a/src/pages/game.tsx b/src/pages/game.tsx index 6a60ba2..2e53be3 100644 --- a/src/pages/game.tsx +++ b/src/pages/game.tsx @@ -1,20 +1,21 @@ -import { LoadingSpinner } from 'components/LoadingSpinner' +import { + NDKFilter, + NDKKind, + NDKSubscriptionCacheUsage +} from '@nostr-dev-kit/ndk' import { ModCard } from 'components/ModCard' import { ModFilter } from 'components/ModsFilter' import { PaginationWithPageNumbers } from 'components/Pagination' import { MAX_MODS_PER_PAGE, T_TAG_VALUE } from 'constants.ts' -import { RelayController } from 'controllers' import { useAppSelector, useFilteredMods, useMuteLists, + useNDKContext, useNSFWList } from 'hooks' -import { Filter, kinds } from 'nostr-tools' -import { Subscription } from 'nostr-tools/abstract-relay' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useState } from 'react' import { useParams } from 'react-router-dom' -import { toast } from 'react-toastify' import { FilterOptions, ModDetails, @@ -22,11 +23,12 @@ import { NSFWFilter, SortBy } from 'types' -import { extractModData, isModDataComplete, log, LogType } from 'utils' +import { extractModData, isModDataComplete } from 'utils' export const GamePage = () => { const params = useParams() const { name: gameName } = params + const { ndk } = useNDKContext() const muteLists = useMuteLists() const nsfwList = useNSFWList() @@ -38,8 +40,6 @@ export const GamePage = () => { }) const [mods, setMods] = useState([]) - const hasEffectRun = useRef(false) - const [isSubscribing, setIsSubscribing] = useState(false) const [currentPage, setCurrentPage] = useState(1) const userState = useAppSelector((state) => state.user) @@ -66,57 +66,40 @@ export const GamePage = () => { } useEffect(() => { - if (hasEffectRun.current) { - return - } - - hasEffectRun.current = true // Set it so the effect doesn't run again - - const filter: Filter = { - kinds: [kinds.ClassifiedListing], + const filter: NDKFilter = { + kinds: [NDKKind.Classified], '#t': [T_TAG_VALUE] } - setIsSubscribing(true) + const subscription = ndk.subscribe(filter, { + cacheUsage: NDKSubscriptionCacheUsage.PARALLEL, + closeOnEose: true + }) - let subscriptions: Subscription[] = [] + subscription.on('event', (ndkEvent) => { + if (isModDataComplete(ndkEvent)) { + const mod = extractModData(ndkEvent) + if (mod.game === gameName) + setMods((prev) => { + if (prev.find((e) => e.aTag === mod.aTag)) return [...prev] - RelayController.getInstance() - .subscribeForEvents(filter, [], (event) => { - if (isModDataComplete(event)) { - const mod = extractModData(event) - if (mod.game === gameName) setMods((prev) => [...prev, mod]) - } - }) - .then((subs) => { - subscriptions = subs - }) - .catch((err) => { - log( - true, - LogType.Error, - 'An error occurred in subscribing to relays.', - err - ) - toast.error(err.message || err) - }) - .finally(() => { - setIsSubscribing(false) - }) + return [...prev, mod] + }) + } + }) - // Cleanup function to stop all subscriptions + subscription.start() + + // Cleanup function to stop subscription return () => { - subscriptions.forEach((sub) => sub.close()) // close each subscription + subscription.stop() } - }, [gameName]) + }, [gameName, ndk]) if (!gameName) return null return ( <> - {isSubscribing && ( - - )}
diff --git a/src/pages/games.tsx b/src/pages/games.tsx index 37d6759..e8b8af5 100644 --- a/src/pages/games.tsx +++ b/src/pages/games.tsx @@ -1,6 +1,6 @@ import { PaginationWithPageNumbers } from 'components/Pagination' import { MAX_GAMES_PER_PAGE } from 'constants.ts' -import { useDidMount, useGames } from 'hooks' +import { useDidMount, useGames, useNDKContext } from 'hooks' import { useMemo, useRef, useState } from 'react' import { GameCard } from '../components/GameCard' import '../styles/pagination.css' @@ -8,10 +8,10 @@ import '../styles/search.css' import '../styles/styles.css' import { createSearchParams, useNavigate } from 'react-router-dom' import { appRoutes } from 'routes' -import { fetchMods } from 'utils' export const GamesPage = () => { const navigate = useNavigate() + const { fetchMods } = useNDKContext() const searchTermRef = useRef(null) const games = useGames() const [gamesWithMods, setGamesWithMods] = useState([]) diff --git a/src/pages/home.tsx b/src/pages/home.tsx index 0b1927c..7778250 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -1,4 +1,4 @@ -import { Filter, nip19 } from 'nostr-tools' +import { nip19 } from 'nostr-tools' import { useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' import { A11y, Autoplay, Navigation, Pagination } from 'swiper/modules' @@ -7,23 +7,23 @@ import { BlogCard } from '../components/BlogCard' import { GameCard } from '../components/GameCard' import { ModCard } from '../components/ModCard' import { LANDING_PAGE_DATA } from '../constants' -import { RelayController } from '../controllers' -import { useDidMount, useGames, useMuteLists, useNSFWList } from '../hooks' +import { + useDidMount, + useGames, + useMuteLists, + useNDKContext, + useNSFWList +} from '../hooks' import { appRoutes, getModPageRoute } from '../routes' import { ModDetails } from '../types' -import { - extractModData, - fetchMods, - handleModImageError, - log, - LogType -} from '../utils' +import { extractModData, handleModImageError, log, LogType } from '../utils' import '../styles/cardLists.css' import '../styles/SimpleSlider.css' import '../styles/styles.css' // Import Swiper styles +import { NDKFilter } from '@nostr-dev-kit/ndk' import 'swiper/css' import 'swiper/css/navigation' import 'swiper/css/pagination' @@ -146,23 +146,23 @@ type SlideContentProps = { const SlideContent = ({ naddr }: SlideContentProps) => { const navigate = useNavigate() + const { fetchEvent } = useNDKContext() const [mod, setMod] = useState() useDidMount(() => { const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`) const { identifier, kind, pubkey, relays = [] } = decoded.data - const filter: Filter = { + const ndkFilter: NDKFilter = { '#a': [identifier], authors: [pubkey], kinds: [kind] } - RelayController.getInstance() - .fetchEvent(filter, relays) - .then((event) => { - if (event) { - const extracted = extractModData(event) + fetchEvent(ndkFilter, relays) + .then((ndkEvent) => { + if (ndkEvent) { + const extracted = extractModData(ndkEvent) setMod(extracted) } }) @@ -220,21 +220,22 @@ type DisplayModProps = { const DisplayMod = ({ naddr }: DisplayModProps) => { const [mod, setMod] = useState() + const { fetchEvent } = useNDKContext() + useDidMount(() => { const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`) const { identifier, kind, pubkey, relays = [] } = decoded.data - const filter: Filter = { + const ndkFilter: NDKFilter = { '#a': [identifier], authors: [pubkey], kinds: [kind] } - RelayController.getInstance() - .fetchEvent(filter, relays) - .then((event) => { - if (event) { - const extracted = extractModData(event) + fetchEvent(ndkFilter, relays) + .then((ndkEvent) => { + if (ndkEvent) { + const extracted = extractModData(ndkEvent) setMod(extracted) } }) @@ -255,6 +256,7 @@ const DisplayMod = ({ naddr }: DisplayModProps) => { const DisplayLatestMods = () => { const navigate = useNavigate() + const { fetchMods } = useNDKContext() const [isFetchingLatestMods, setIsFetchingLatestMods] = useState(true) const [latestMods, setLatestMods] = useState([]) @@ -263,8 +265,7 @@ const DisplayLatestMods = () => { useDidMount(() => { fetchMods({ source: window.location.host }) - .then((res) => { - const mods = res.sort((a, b) => b.published_at - a.published_at) + .then((mods) => { setLatestMods(mods) }) .finally(() => { diff --git a/src/pages/mod/index.tsx b/src/pages/mod/index.tsx index 1c3cee6..301823c 100644 --- a/src/pages/mod/index.tsx +++ b/src/pages/mod/index.tsx @@ -1,21 +1,18 @@ +import { NDKFilter, NDKKind } from '@nostr-dev-kit/ndk' import Link from '@tiptap/extension-link' import { EditorContent, useEditor } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import { formatDate } from 'date-fns' import FsLightbox from 'fslightbox-react' -import { Filter, kinds, nip19, UnsignedEvent } from 'nostr-tools' +import { nip19, UnsignedEvent } from 'nostr-tools' import { useEffect, useRef, useState } from 'react' import { Link as ReactRouterLink, useParams } from 'react-router-dom' import { toast } from 'react-toastify' import { BlogCard } from '../../components/BlogCard' import { LoadingSpinner } from '../../components/LoadingSpinner' import { ProfileSection } from '../../components/ProfileSection' -import { - MetadataController, - RelayController, - UserRelaysType -} from '../../controllers' -import { useAppSelector, useDidMount } 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' @@ -47,6 +44,7 @@ import { Zap } from './internal/zap' export const ModPage = () => { const { naddr } = useParams() + const { fetchEvent } = useNDKContext() const [modData, setModData] = useState() const [isFetching, setIsFetching] = useState(true) const [commentCount, setCommentCount] = useState(0) @@ -56,14 +54,13 @@ export const ModPage = () => { const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`) const { identifier, kind, pubkey, relays = [] } = decoded.data - const filter: Filter = { + const filter: NDKFilter = { '#a': [identifier], authors: [pubkey], kinds: [kind] } - RelayController.getInstance() - .fetchEvent(filter, relays) + fetchEvent(filter, relays) .then((event) => { if (event) { const extracted = extractModData(event) @@ -214,6 +211,8 @@ type GameProps = { } const Game = ({ naddr, game, author, aTag }: GameProps) => { + const { fetchEventFromUserRelays } = useNDKContext() + const userState = useAppSelector((state) => state.user) const [isLoading, setIsLoading] = useState(false) const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') @@ -225,56 +224,54 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => { if (userState.auth && userState.user?.pubkey) { const pubkey = userState.user.pubkey as string - const muteListFilter: Filter = { - kinds: [kinds.Mutelist], + const muteListFilter: NDKFilter = { + kinds: [NDKKind.MuteList], authors: [pubkey] } - RelayController.getInstance() - .fetchEventFromUserRelays(muteListFilter, pubkey, UserRelaysType.Write) - .then((event) => { - if (event) { - // get a list of tags - const tags = event.tags - const blocked = - tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== - -1 + fetchEventFromUserRelays( + muteListFilter, + pubkey, + UserRelaysType.Write + ).then((event) => { + if (event) { + // get a list of tags + const tags = event.tags + const blocked = + tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== -1 - setIsBlocked(blocked) - } - }) + setIsBlocked(blocked) + } + }) if ( userState.user.npub && userState.user.npub === import.meta.env.VITE_REPORTING_NPUB ) { - const nsfwListFilter: Filter = { - kinds: [kinds.Curationsets], + const nsfwListFilter: NDKFilter = { + kinds: [NDKKind.ArticleCurationSet], authors: [pubkey], '#d': ['nsfw'] } - RelayController.getInstance() - .fetchEventFromUserRelays( - nsfwListFilter, - pubkey, - UserRelaysType.Write - ) - .then((event) => { - if (event) { - // get a list of tags - const tags = event.tags - const existsInNSFWList = - tags.findIndex( - (item) => item[0] === 'a' && item[1] === aTag - ) !== -1 + fetchEventFromUserRelays( + nsfwListFilter, + pubkey, + UserRelaysType.Write + ).then((event) => { + if (event) { + // get a list of tags + const tags = event.tags + const existsInNSFWList = + tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== + -1 - setIsAddedToNSFW(existsInNSFWList) - } - }) + setIsAddedToNSFW(existsInNSFWList) + } + }) } } - }, [userState, aTag]) + }, [userState, aTag, fetchEventFromUserRelays]) const handleBlock = async () => { let hexPubkey: string @@ -298,18 +295,17 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => { // Define the event filter to search for the user's mute list events. // We look for events of a specific kind (Mutelist) authored by the given hexPubkey. - const filter: Filter = { - kinds: [kinds.Mutelist], + const filter: NDKFilter = { + kinds: [NDKKind.MuteList], authors: [hexPubkey] } // Fetch the mute list event from the relays. This returns the event containing the user's mute list. - const muteListEvent = - await RelayController.getInstance().fetchEventFromUserRelays( - filter, - hexPubkey, - UserRelaysType.Write - ) + const muteListEvent = await fetchEventFromUserRelays( + filter, + hexPubkey, + UserRelaysType.Write + ) let unsignedEvent: UnsignedEvent @@ -329,7 +325,7 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => { unsignedEvent = { pubkey: muteListEvent.pubkey, - kind: muteListEvent.kind, + kind: NDKKind.MuteList, content: muteListEvent.content, created_at: now(), tags: [...tags] @@ -337,7 +333,7 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => { } else { unsignedEvent = { pubkey: hexPubkey, - kind: kinds.Mutelist, + kind: NDKKind.MuteList, content: '', created_at: now(), tags: [['a', aTag]] @@ -356,8 +352,8 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => { const handleUnblock = async () => { const pubkey = userState.user?.pubkey as string - const filter: Filter = { - kinds: [kinds.Mutelist], + const filter: NDKFilter = { + kinds: [NDKKind.MuteList], authors: [pubkey] } @@ -365,12 +361,11 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => { setLoadingSpinnerDesc(`Finding user's mute list`) // Fetch the mute list event from the relays. This returns the event containing the user's mute list. - const muteListEvent = - await RelayController.getInstance().fetchEventFromUserRelays( - filter, - pubkey, - UserRelaysType.Write - ) + const muteListEvent = await fetchEventFromUserRelays( + filter, + pubkey, + UserRelaysType.Write + ) if (!muteListEvent) { toast.error(`Couldn't get user's mute list event from relays`) @@ -381,7 +376,7 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => { const unsignedEvent: UnsignedEvent = { pubkey: muteListEvent.pubkey, - kind: muteListEvent.kind, + kind: NDKKind.MuteList, content: muteListEvent.content, created_at: now(), tags: tags.filter((item) => item[0] !== 'a' || item[1] !== aTag) @@ -401,8 +396,8 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => { if (!pubkey) return - const filter: Filter = { - kinds: [kinds.Curationsets], + const filter: NDKFilter = { + kinds: [NDKKind.ArticleCurationSet], authors: [pubkey], '#d': ['nsfw'] } @@ -410,12 +405,11 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => { setIsLoading(true) setLoadingSpinnerDesc('Finding NSFW list') - const nsfwListEvent = - await RelayController.getInstance().fetchEventFromUserRelays( - filter, - pubkey, - UserRelaysType.Write - ) + const nsfwListEvent = await fetchEventFromUserRelays( + filter, + pubkey, + UserRelaysType.Write + ) let unsignedEvent: UnsignedEvent @@ -435,7 +429,7 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => { unsignedEvent = { pubkey: nsfwListEvent.pubkey, - kind: nsfwListEvent.kind, + kind: NDKKind.ArticleCurationSet, content: nsfwListEvent.content, created_at: now(), tags: [...tags] @@ -443,7 +437,7 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => { } else { unsignedEvent = { pubkey: pubkey, - kind: kinds.Curationsets, + kind: NDKKind.ArticleCurationSet, content: '', created_at: now(), tags: [ @@ -465,8 +459,8 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => { const handleUnblockNSFW = async () => { const pubkey = userState.user?.pubkey as string - const filter: Filter = { - kinds: [kinds.Curationsets], + const filter: NDKFilter = { + kinds: [NDKKind.ArticleCurationSet], authors: [pubkey], '#d': ['nsfw'] } @@ -474,12 +468,11 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => { setIsLoading(true) setLoadingSpinnerDesc('Finding NSFW list') - const nsfwListEvent = - await RelayController.getInstance().fetchEventFromUserRelays( - filter, - pubkey, - UserRelaysType.Write - ) + const nsfwListEvent = await fetchEventFromUserRelays( + filter, + pubkey, + UserRelaysType.Write + ) if (!nsfwListEvent) { toast.error(`Couldn't get nsfw list event from relays`) @@ -490,7 +483,7 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => { const unsignedEvent: UnsignedEvent = { pubkey: nsfwListEvent.pubkey, - kind: nsfwListEvent.kind, + kind: NDKKind.ArticleCurationSet, content: nsfwListEvent.content, created_at: now(), tags: tags.filter((item) => item[0] !== 'a' || item[1] !== aTag) @@ -667,6 +660,7 @@ type ReportPopupProps = { } const ReportPopup = ({ aTag, handleClose }: ReportPopupProps) => { + const { fetchEventFromUserRelays } = useNDKContext() const userState = useAppSelector((state) => state.user) const [selectedOptions, setSelectedOptions] = useState({ actuallyCP: false, @@ -720,18 +714,17 @@ const ReportPopup = ({ aTag, handleClose }: ReportPopupProps) => { setLoadingSpinnerDesc(`Finding user's mute list`) // Define the event filter to search for the user's mute list events. // We look for events of a specific kind (Mutelist) authored by the given hexPubkey. - const filter: Filter = { - kinds: [kinds.Mutelist], + const filter: NDKFilter = { + kinds: [NDKKind.MuteList], authors: [hexPubkey] } // Fetch the mute list event from the relays. This returns the event containing the user's mute list. - const muteListEvent = - await RelayController.getInstance().fetchEventFromUserRelays( - filter, - hexPubkey, - UserRelaysType.Write - ) + const muteListEvent = await fetchEventFromUserRelays( + filter, + hexPubkey, + UserRelaysType.Write + ) let unsignedEvent: UnsignedEvent @@ -750,7 +743,7 @@ const ReportPopup = ({ aTag, handleClose }: ReportPopupProps) => { unsignedEvent = { pubkey: muteListEvent.pubkey, - kind: muteListEvent.kind, + kind: NDKKind.MuteList, content: muteListEvent.content, created_at: now(), tags: [...tags] @@ -758,7 +751,7 @@ const ReportPopup = ({ aTag, handleClose }: ReportPopupProps) => { } else { unsignedEvent = { pubkey: hexPubkey, - kind: kinds.Mutelist, + kind: NDKKind.MuteList, content: '', created_at: now(), tags: [['a', aTag]] diff --git a/src/pages/mod/internal/comment/index.tsx b/src/pages/mod/internal/comment/index.tsx index 62c5099..2f5b721 100644 --- a/src/pages/mod/internal/comment/index.tsx +++ b/src/pages/mod/internal/comment/index.tsx @@ -5,7 +5,7 @@ import { UserRelaysType } from 'controllers' import { formatDate } from 'date-fns' -import { useAppSelector, useDidMount, useReactions } from 'hooks' +import { useAppSelector, useDidMount, useNDKContext, useReactions } from 'hooks' import { useComments } from 'hooks/useComments' import { Event, kinds, nip19, UnsignedEvent } from 'nostr-tools' import React, { @@ -322,11 +322,11 @@ const Filter = React.memo( ) const Comment = (props: CommentEvent) => { + const { findMetadata } = useNDKContext() const [profile, setProfile] = useState() - useDidMount(async () => { - const metadataController = await MetadataController.getInstance() - metadataController.findMetadata(props.pubkey).then((res) => { + useDidMount(() => { + findMetadata(props.pubkey).then((res) => { setProfile(res) }) }) diff --git a/src/pages/mods.tsx b/src/pages/mods.tsx index 760d449..444eb08 100644 --- a/src/pages/mods.tsx +++ b/src/pages/mods.tsx @@ -9,6 +9,7 @@ import { useAppSelector, useFilteredMods, useMuteLists, + useNDKContext, useNSFWList } from '../hooks' import { appRoutes } from '../routes' @@ -23,9 +24,9 @@ import { NSFWFilter, SortBy } from '../types' -import { fetchMods } from '../utils' export const ModsPage = () => { + const { fetchMods } = useNDKContext() const [isFetching, setIsFetching] = useState(false) const [mods, setMods] = useState([]) const [filterOptions, setFilterOptions] = useState({ @@ -50,7 +51,7 @@ export const ModsPage = () => { .finally(() => { setIsFetching(false) }) - }, [filterOptions.source]) + }, [filterOptions.source, fetchMods]) const handleNext = useCallback(() => { setIsFetching(true) @@ -69,7 +70,7 @@ export const ModsPage = () => { .finally(() => { setIsFetching(false) }) - }, [filterOptions.source, mods]) + }, [filterOptions.source, mods, fetchMods]) const handlePrev = useCallback(() => { setIsFetching(true) @@ -87,7 +88,7 @@ export const ModsPage = () => { .finally(() => { setIsFetching(false) }) - }, [filterOptions.source, mods]) + }, [filterOptions.source, mods, fetchMods]) const filteredModList = useFilteredMods( mods, diff --git a/src/pages/search.tsx b/src/pages/search.tsx index 7b60cb2..41aebcb 100644 --- a/src/pages/search.tsx +++ b/src/pages/search.tsx @@ -1,4 +1,11 @@ -import { NDKEvent, NDKUserProfile, profileFromEvent } from '@nostr-dev-kit/ndk' +import { + NDKEvent, + NDKFilter, + NDKKind, + NDKSubscriptionCacheUsage, + NDKUserProfile, + profileFromEvent +} from '@nostr-dev-kit/ndk' import { ErrorBoundary } from 'components/ErrorBoundary' import { GameCard } from 'components/GameCard' import { LoadingSpinner } from 'components/LoadingSpinner' @@ -11,16 +18,14 @@ import { MAX_MODS_PER_PAGE, T_TAG_VALUE } from 'constants.ts' -import { RelayController } from 'controllers' import { useAppSelector, useFilteredMods, useGames, useMuteLists, + useNDKContext, useNSFWList } from 'hooks' -import { Filter, kinds } from 'nostr-tools' -import { Subscription } from 'nostr-tools/abstract-relay' import React, { Dispatch, SetStateAction, @@ -30,7 +35,6 @@ import React, { useState } from 'react' import { useSearchParams } from 'react-router-dom' -import { toast } from 'react-toastify' import { FilterOptions, ModDetails, @@ -267,56 +271,38 @@ const ModsResult = ({ muteLists, nsfwList }: ModsResultProps) => { - const hasEffectRun = useRef(false) - const [isSubscribing, setIsSubscribing] = useState(false) + const { ndk } = useNDKContext() const [mods, setMods] = useState([]) const [page, setPage] = useState(1) const userState = useAppSelector((state) => state.user) useEffect(() => { - if (hasEffectRun.current) { - return - } - - hasEffectRun.current = true // Set it so the effect doesn't run again - - const filter: Filter = { - kinds: [kinds.ClassifiedListing], + const filter: NDKFilter = { + kinds: [NDKKind.Classified], '#t': [T_TAG_VALUE] } - setIsSubscribing(true) + const subscription = ndk.subscribe(filter, { + cacheUsage: NDKSubscriptionCacheUsage.PARALLEL, + closeOnEose: true + }) - let subscriptions: Subscription[] = [] + subscription.on('event', (ndkEvent) => { + if (isModDataComplete(ndkEvent)) { + const mod = extractModData(ndkEvent) + setMods((prev) => { + if (prev.find((e) => e.aTag === mod.aTag)) return [...prev] - RelayController.getInstance() - .subscribeForEvents(filter, [], (event) => { - if (isModDataComplete(event)) { - const mod = extractModData(event) - setMods((prev) => [...prev, mod]) - } - }) - .then((subs) => { - subscriptions = subs - }) - .catch((err) => { - log( - true, - LogType.Error, - 'An error occurred in subscribing to relays.', - err - ) - toast.error(err.message || err) - }) - .finally(() => { - setIsSubscribing(false) - }) + return [...prev, mod] + }) + } + }) // Cleanup function to stop all subscriptions return () => { - subscriptions.forEach((sub) => sub.close()) // close each subscription + subscription.stop() } - }, []) + }, [ndk]) useEffect(() => { setPage(1) @@ -357,9 +343,6 @@ const ModsResult = ({ return ( <> - {isSubscribing && ( - - )}
{filteredModList @@ -393,6 +376,7 @@ const UsersResult = ({ moderationFilter, muteLists }: UsersResultProps) => { + const { fetchEvents } = useNDKContext() const [isFetching, setIsFetching] = useState(false) const [profiles, setProfiles] = useState([]) @@ -402,14 +386,13 @@ const UsersResult = ({ if (searchTerm === '') { setProfiles([]) } else { - const filter: Filter = { - kinds: [kinds.Metadata], + const filter: NDKFilter = { + kinds: [NDKKind.Metadata], search: searchTerm } setIsFetching(true) - RelayController.getInstance() - .fetchEvents(filter, ['wss://purplepag.es', 'wss://user.kindpag.es']) + fetchEvents(filter, ['wss://purplepag.es', 'wss://user.kindpag.es']) .then((events) => { const results = events.map((event) => { const ndkEvent = new NDKEvent(undefined, event) @@ -425,7 +408,7 @@ const UsersResult = ({ setIsFetching(false) }) } - }, [searchTerm]) + }, [searchTerm, fetchEvents]) const filteredProfiles = useMemo(() => { let filtered = [...profiles] diff --git a/src/pages/submitMod.tsx b/src/pages/submitMod.tsx index 1a07fcc..226b896 100644 --- a/src/pages/submitMod.tsx +++ b/src/pages/submitMod.tsx @@ -1,21 +1,22 @@ +import { NDKFilter } from '@nostr-dev-kit/ndk' +import { nip19 } from 'nostr-tools' +import { useState } from 'react' import { useLocation, useParams } from 'react-router-dom' +import { toast } from 'react-toastify' +import { LoadingSpinner } from '../components/LoadingSpinner' import { ModForm } from '../components/ModForm' import { ProfileSection } from '../components/ProfileSection' +import { useAppSelector, useDidMount, useNDKContext } from '../hooks' import '../styles/innerPage.css' import '../styles/styles.css' import '../styles/write.css' -import { Filter, nip19 } from 'nostr-tools' -import { RelayController } from '../controllers' -import { extractModData, log, LogType } from '../utils' import { ModDetails } from '../types' -import { toast } from 'react-toastify' -import { useState } from 'react' -import { LoadingSpinner } from '../components/LoadingSpinner' -import { useAppSelector, useDidMount } from '../hooks' +import { extractModData, log, LogType } from '../utils' export const SubmitModPage = () => { const location = useLocation() const { naddr } = useParams() + const { fetchEvent } = useNDKContext() const [modData, setModData] = useState() const [isFetching, setIsFetching] = useState(false) @@ -30,15 +31,15 @@ export const SubmitModPage = () => { const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`) const { identifier, kind, pubkey, relays = [] } = decoded.data - const filter: Filter = { + const filter: NDKFilter = { '#a': [identifier], authors: [pubkey], kinds: [kind] } setIsFetching(true) - RelayController.getInstance() - .fetchEvent(filter, relays) + + fetchEvent(filter, relays) .then((event) => { if (event) { const extracted = extractModData(event) diff --git a/src/utils/mod.ts b/src/utils/mod.ts index 6e3511d..09537fe 100644 --- a/src/utils/mod.ts +++ b/src/utils/mod.ts @@ -1,11 +1,7 @@ -import { Event, Filter, kinds } from 'nostr-tools' +import { NDKEvent } from '@nostr-dev-kit/ndk' +import { Event } from 'nostr-tools' +import { ModDetails, ModFormState } from '../types' import { getTagValue } from './nostr' -import { ModFormState, ModDetails } from '../types' -import { RelayController } from '../controllers' -import { log, LogType } from './utils' -import { toast } from 'react-toastify' -import { MOD_FILTER_LIMIT, T_TAG_VALUE } from '../constants' -import DOMPurify from 'dompurify' /** * Extracts and normalizes mod data from an event. @@ -16,7 +12,7 @@ import DOMPurify from 'dompurify' * @param event - The event object from which to extract data. * @returns A `Partial` object containing extracted data. */ -export const extractModData = (event: Event): ModDetails => { +export const extractModData = (event: Event | NDKEvent): ModDetails => { // Helper function to safely get the first value of a tag or return a default value const getFirstTagValue = (tagIdentifier: string, defaultValue = '') => { const tagValue = getTagValue(event, tagIdentifier) @@ -35,7 +31,7 @@ export const extractModData = (event: Event): ModDetails => { aTag: getFirstTagValue('a'), rTag: getFirstTagValue('r'), author: event.pubkey, - edited_at: event.created_at, + edited_at: event.created_at!, body: event.content, published_at: getIntTagValue('published_at'), game: getFirstTagValue('game'), @@ -61,7 +57,9 @@ export const extractModData = (event: Event): ModDetails => { * @param events - The array of event objects to be processed. * @returns An array of `ModDetails` objects constructed from valid events. */ -export const constructModListFromEvents = (events: Event[]): ModDetails[] => { +export const constructModListFromEvents = ( + events: Event[] | NDKEvent[] +): ModDetails[] => { // Filter and extract mod details from events const modDetailsList: ModDetails[] = events .filter(isModDataComplete) // Filter out incomplete events @@ -78,7 +76,7 @@ export const constructModListFromEvents = (events: Event[]): ModDetails[] => { * @param event - The event object to be checked. * @returns `true` if the event contains all required data; `false` otherwise. */ -export const isModDataComplete = (event: Event): boolean => { +export const isModDataComplete = (event: Event | NDKEvent): boolean => { // Helper function to check if a tag value is present and not empty const hasTagValue = (tagIdentifier: string): boolean => { const value = getTagValue(event, tagIdentifier) @@ -133,89 +131,3 @@ export const initializeFormState = ( } ] }) - -interface FetchModsOptions { - source?: string - until?: number - since?: number - limit?: number -} - -/** - * Fetches a list of mods based on the provided source. - * - * @param source - The source URL to filter the mods. If it matches the current window location, - * it adds a filter condition to the request. - * @param until - Optional timestamp to filter events until this time. - * @param since - Optional timestamp to filter events from this time. - * @returns A promise that resolves to an array of `ModDetails` objects. In case of an error, - * it logs the error and shows a notification, then returns an empty array. - */ -export const fetchMods = async ({ - source, - until, - since, - limit -}: FetchModsOptions): Promise => { - // Define the filter criteria for fetching mods - const filter: Filter = { - kinds: [kinds.ClassifiedListing], // Specify the kind of events to fetch - limit: limit || MOD_FILTER_LIMIT, // Limit the number of events fetched to 20 - '#t': [T_TAG_VALUE], - until, // Optional filter to fetch events until this timestamp - since // Optional filter to fetch events from this timestamp - } - - // If the source matches the current window location, add a filter condition - if (source === window.location.host) { - filter['#r'] = [window.location.host] // Add a tag filter for the current host - } - - // Fetch events from the relay using the defined filter - return RelayController.getInstance() - .fetchEvents(filter, []) // Pass the filter and an empty array of options - .then((events) => { - // Convert the fetched events into a list of mods - const modList = constructModListFromEvents(events) - return modList // Return the list of mods - }) - .catch((err) => { - // Log the error and show a notification if fetching fails - log( - true, - LogType.Error, - 'An error occurred in fetching mods from relays', - err - ) - toast.error('An error occurred in fetching mods from relays') // Show error notification - return [] as ModDetails[] // Return an empty array in case of an error - }) -} - -/** - * Sanitizes the given HTML string and adds target="_blank" to all tags. - * - * @param htmlString - The HTML string to sanitize and modify. - * @returns The modified HTML string with sanitized content and updated links. - */ -export const sanitizeAndAddTargetBlank = (htmlString: string) => { - // Step 1: Sanitize the HTML string using DOMPurify. - // This removes any potentially dangerous content and ensures that the HTML is safe to use. - const sanitizedHtml = DOMPurify.sanitize(htmlString, { ADD_ATTR: ['target'] }) - - // Step 2: Create a temporary container (a
element) to parse the sanitized HTML. - // This allows us to manipulate the HTML content in a safe and controlled manner. - const tempDiv = document.createElement('div') - tempDiv.innerHTML = sanitizedHtml - - // Step 3: Add target="_blank" to all tags within the temporary container. - // This ensures that all links open in a new tab when clicked. - const links = tempDiv.querySelectorAll('a') - links.forEach((link) => { - link.setAttribute('target', '_blank') - }) - - // Step 4: Convert the manipulated DOM back to an HTML string. - // This string contains the sanitized content with the target="_blank" attribute added to all links. - return tempDiv.innerHTML -} diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index 234c69d..f3711b0 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -11,6 +11,7 @@ import { import { toast } from 'react-toastify' import { RelayController } from '../controllers' import { log, LogType } from './utils' +import { NDKEvent } from '@nostr-dev-kit/ndk' /** * Get the current time in seconds since the Unix epoch (January 1, 1970). @@ -50,7 +51,7 @@ export const hexToNpub = (hexPubkey: string): `npub1${string}` => { * @returns {string | null} The value(s) associated with the specified tag identifier, or `null` if the tag is not found. */ export const getTagValue = ( - event: Event, + event: Event | NDKEvent, tagIdentifier: string ): string[] | null => { // Find the tag in the event's tags array where the first element matches the tagIdentifier. @@ -219,3 +220,24 @@ export const sendDMUsingRandomKey = async ( // Return true indicating that the DM was successfully sent return true } + +/** + * 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 +}