Compare commits

..

No commits in common. "3f9e39a92a2335520ec12bfe611195163be9e541" and "7aef0b3456d4a461cc5665b96bcd7956a2bbd4c8" have entirely different histories.

24 changed files with 702 additions and 819 deletions

64
package-lock.json generated
View File

@ -10,7 +10,6 @@
"dependencies": { "dependencies": {
"@getalby/lightning-tools": "5.0.3", "@getalby/lightning-tools": "5.0.3",
"@nostr-dev-kit/ndk": "2.10.0", "@nostr-dev-kit/ndk": "2.10.0",
"@nostr-dev-kit/ndk-cache-dexie": "2.5.1",
"@reduxjs/toolkit": "2.2.6", "@reduxjs/toolkit": "2.2.6",
"@tiptap/core": "2.6.6", "@tiptap/core": "2.6.6",
"@tiptap/extension-link": "2.6.6", "@tiptap/extension-link": "2.6.6",
@ -20,7 +19,6 @@
"bech32": "2.0.0", "bech32": "2.0.0",
"buffer": "6.0.3", "buffer": "6.0.3",
"date-fns": "3.6.0", "date-fns": "3.6.0",
"dexie": "4.0.8",
"dompurify": "3.1.6", "dompurify": "3.1.6",
"file-saver": "2.0.5", "file-saver": "2.0.5",
"fslightbox-react": "1.7.6", "fslightbox-react": "1.7.6",
@ -1045,25 +1043,22 @@
} }
}, },
"node_modules/@noble/curves": { "node_modules/@noble/curves": {
"version": "1.6.0", "version": "1.4.2",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.6.0.tgz", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.4.2.tgz",
"integrity": "sha512-TlaHRXDehJuRNR9TfZDNQ45mMEd5dwUwmicsafcIX4SsNiqnCHKjE/1alYPd/lDRVhxdhUAlv8uEhMCI5zjIJQ==", "integrity": "sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw==",
"dependencies": { "dependencies": {
"@noble/hashes": "1.5.0" "@noble/hashes": "1.4.0"
},
"engines": {
"node": "^14.21.3 || >=16"
}, },
"funding": { "funding": {
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/@noble/hashes": { "node_modules/@noble/hashes": {
"version": "1.5.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.5.0.tgz", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz",
"integrity": "sha512-1j6kQFb7QRru7eKN3ZDvRcP13rugwdxZqCjbiAVZfIJwgj2A65UmT4TgARXGlXgnRkORLTDTrO19ZErt7+QXgA==", "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==",
"engines": { "engines": {
"node": "^14.21.3 || >=16" "node": ">= 16"
}, },
"funding": { "funding": {
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
@ -1134,18 +1129,6 @@
"node": ">=16" "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": { "node_modules/@popperjs/core": {
"version": "2.11.8", "version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@ -1400,9 +1383,9 @@
] ]
}, },
"node_modules/@scure/base": { "node_modules/@scure/base": {
"version": "1.1.9", "version": "1.1.7",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz", "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.7.tgz",
"integrity": "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==", "integrity": "sha512-PPNYBslrLNNUQ/Yad37MHYsNQtK67EhWb6WtSvNLLPo7SdVZgkUjD6Dg+5On7zNwmskf8OX7I7Nx5oN+MIWE0g==",
"funding": { "funding": {
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
@ -2687,11 +2670,11 @@
} }
}, },
"node_modules/debug": { "node_modules/debug": {
"version": "4.3.7", "version": "4.3.5",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "2.1.2"
}, },
"engines": { "engines": {
"node": ">=6.0" "node": ">=6.0"
@ -2716,11 +2699,6 @@
"node": ">=0.4.0" "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": { "node_modules/diff": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
@ -3740,9 +3718,9 @@
} }
}, },
"node_modules/light-bolt11-decoder": { "node_modules/light-bolt11-decoder": {
"version": "3.2.0", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/light-bolt11-decoder/-/light-bolt11-decoder-3.2.0.tgz", "resolved": "https://registry.npmjs.org/light-bolt11-decoder/-/light-bolt11-decoder-3.1.1.tgz",
"integrity": "sha512-3QEofgiBOP4Ehs9BI+RkZdXZNtSys0nsJ6fyGeSiAGCBsMwHGUDS/JQlY/sTnWs91A2Nh0S9XXfA8Sy9g6QpuQ==", "integrity": "sha512-sLg/KCwYkgsHWkefWd6KqpCHrLFWWaXTOX3cf6yD2hAzL0SLpX+lFcaFK2spkjbgzG6hhijKfORDc9WoUHwX0A==",
"dependencies": { "dependencies": {
"@scure/base": "1.1.1" "@scure/base": "1.1.1"
} }
@ -3906,9 +3884,9 @@
} }
}, },
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.7", "version": "3.3.7",

View File

@ -12,7 +12,6 @@
"dependencies": { "dependencies": {
"@getalby/lightning-tools": "5.0.3", "@getalby/lightning-tools": "5.0.3",
"@nostr-dev-kit/ndk": "2.10.0", "@nostr-dev-kit/ndk": "2.10.0",
"@nostr-dev-kit/ndk-cache-dexie": "2.5.1",
"@reduxjs/toolkit": "2.2.6", "@reduxjs/toolkit": "2.2.6",
"@tiptap/core": "2.6.6", "@tiptap/core": "2.6.6",
"@tiptap/extension-link": "2.6.6", "@tiptap/extension-link": "2.6.6",
@ -22,7 +21,6 @@
"bech32": "2.0.0", "bech32": "2.0.0",
"buffer": "6.0.3", "buffer": "6.0.3",
"date-fns": "3.6.0", "date-fns": "3.6.0",
"dexie": "4.0.8",
"dompurify": "3.1.6", "dompurify": "3.1.6",
"file-saver": "2.0.5", "file-saver": "2.0.5",
"fslightbox-react": "1.7.6", "fslightbox-react": "1.7.6",

View File

@ -4,8 +4,12 @@ import { QRCodeSVG } from 'qrcode.react'
import { useState } from 'react' import { useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { RelayController, UserRelaysType } from '../controllers' import {
import { useAppSelector, useDidMount, useNDKContext } from '../hooks' MetadataController,
RelayController,
UserRelaysType
} from '../controllers'
import { useAppSelector, useDidMount } from '../hooks'
import { appRoutes, getProfilePageRoute } from '../routes' import { appRoutes, getProfilePageRoute } from '../routes'
import '../styles/author.css' import '../styles/author.css'
import '../styles/innerPage.css' import '../styles/innerPage.css'
@ -27,11 +31,11 @@ type Props = {
} }
export const ProfileSection = ({ pubkey }: Props) => { export const ProfileSection = ({ pubkey }: Props) => {
const { findMetadata } = useNDKContext()
const [profile, setProfile] = useState<UserProfile>() const [profile, setProfile] = useState<UserProfile>()
useDidMount(() => { useDidMount(async () => {
findMetadata(pubkey).then((res) => { const metadataController = await MetadataController.getInstance()
metadataController.findMetadata(pubkey).then((res) => {
setProfile(res) setProfile(res)
}) })
}) })
@ -367,7 +371,6 @@ type FollowButtonProps = {
} }
const FollowButton = ({ pubkey }: FollowButtonProps) => { const FollowButton = ({ pubkey }: FollowButtonProps) => {
const { fetchEventFromUserRelays } = useNDKContext()
const [isFollowing, setIsFollowing] = useState(false) const [isFollowing, setIsFollowing] = useState(false)
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
@ -406,11 +409,12 @@ const FollowButton = ({ pubkey }: FollowButtonProps) => {
authors: [userHexKey] authors: [userHexKey]
} }
const contactListEvent = await fetchEventFromUserRelays( const contactListEvent =
filter, await RelayController.getInstance().fetchEventFromUserRelays(
userHexKey, filter,
UserRelaysType.Both userHexKey,
) UserRelaysType.Both
)
if (!contactListEvent) if (!contactListEvent)
return { return {
@ -509,11 +513,12 @@ const FollowButton = ({ pubkey }: FollowButtonProps) => {
authors: [userHexKey] authors: [userHexKey]
} }
const contactListEvent = await fetchEventFromUserRelays( const contactListEvent =
filter, await RelayController.getInstance().fetchEventFromUserRelays(
userHexKey, filter,
UserRelaysType.Both userHexKey,
) UserRelaysType.Both
)
if ( if (
!contactListEvent || !contactListEvent ||

View File

@ -10,7 +10,7 @@ import React, {
import Countdown, { CountdownRenderProps } from 'react-countdown' import Countdown, { CountdownRenderProps } from 'react-countdown'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { MetadataController, ZapController } from '../controllers' import { MetadataController, ZapController } from '../controllers'
import { useAppSelector, useDidMount, useNDKContext } from '../hooks' import { useAppSelector, useDidMount } from '../hooks'
import '../styles/popup.css' import '../styles/popup.css'
import { PaymentRequest, UserProfile } from '../types' import { PaymentRequest, UserProfile } from '../types'
import { import {
@ -133,11 +133,9 @@ export const ZapQR = React.memo(
setTotalZapAmount, setTotalZapAmount,
setHasZapped setHasZapped
}: ZapQRProps) => { }: ZapQRProps) => {
const { ndk } = useNDKContext()
useDidMount(() => { useDidMount(() => {
ZapController.getInstance() ZapController.getInstance()
.pollZapReceipt(paymentRequest, ndk) .pollZapReceipt(paymentRequest)
.then((zapReceipt) => { .then((zapReceipt) => {
toast.success(`Successfully sent sats!`) toast.success(`Successfully sent sats!`)
if (setTotalZapAmount) { if (setTotalZapAmount) {
@ -251,7 +249,6 @@ export const ZapPopUp = ({
setHasZapped, setHasZapped,
handleClose handleClose
}: ZapPopUpProps) => { }: ZapPopUpProps) => {
const { findMetadata } = useNDKContext()
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [amount, setAmount] = useState<number>(0) const [amount, setAmount] = useState<number>(0)
@ -285,8 +282,9 @@ export const ZapPopUp = ({
} }
setLoadingSpinnerDesc('finding receiver metadata') setLoadingSpinnerDesc('finding receiver metadata')
const metadataController = await MetadataController.getInstance()
const receiverMetadata = await findMetadata(receiver) const receiverMetadata = await metadataController.findMetadata(receiver)
if (!receiverMetadata?.lud16) { if (!receiverMetadata?.lud16) {
setIsLoading(false) setIsLoading(false)
@ -482,7 +480,6 @@ export const ZapSplit = ({
setHasZapped, setHasZapped,
handleClose handleClose
}: ZapSplitProps) => { }: ZapSplitProps) => {
const { findMetadata } = useNDKContext()
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [amount, setAmount] = useState<number>(0) const [amount, setAmount] = useState<number>(0)
@ -498,12 +495,12 @@ export const ZapSplit = ({
const [invoices, setInvoices] = useState<Map<string, PaymentRequest>>() const [invoices, setInvoices] = useState<Map<string, PaymentRequest>>()
useDidMount(async () => { useDidMount(async () => {
findMetadata(pubkey).then((res) => { const metadataController = await MetadataController.getInstance()
metadataController.findMetadata(pubkey).then((res) => {
setAuthor(res) setAuthor(res)
}) })
const metadataController = await MetadataController.getInstance() metadataController.findAdminMetadata().then((res) => {
findMetadata(metadataController.adminNpubs[0]).then((res) => {
setAdmin(res) setAdmin(res)
}) })
}) })

View File

@ -1,347 +0,0 @@
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<ModDetails[]>
fetchEvents: (filter: NDKFilter, relayUrls?: string[]) => Promise<NDKEvent[]>
fetchEvent: (
filter: NDKFilter,
relayUrls?: string[]
) => Promise<NDKEvent | null>
fetchEventsFromUserRelays: (
filter: NDKFilter,
hexKey: string,
userRelaysType: UserRelaysType
) => Promise<NDKEvent[]>
fetchEventFromUserRelays: (
filter: NDKFilter,
hexKey: string,
userRelaysType: UserRelaysType
) => Promise<NDKEvent | null>
findMetadata: (pubkey: string) => Promise<UserProfile>
}
// Create the context with an initial value of `null`
export const NDKContext = createContext<NDKContextType | null>(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<ModDetails[]> => {
const relays = new Set<string>()
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<NDKEvent[]> => {
const relays = new Set<string>()
// 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<UserProfile> => {
const npub = hexToNpub(pubkey)
const user = new NDKUser({ npub })
user.ndk = ndk
const userProfile = await user.fetchProfile()
return userProfile
}
return (
<NDKContext.Provider
value={{
ndk,
fetchMods,
fetchEvents,
fetchEvent,
fetchEventsFromUserRelays,
fetchEventFromUserRelays,
findMetadata
}}
>
{children}
</NDKContext.Provider>
)
}

View File

@ -1,7 +1,8 @@
import NDK, { getRelayListForUser, NDKList } from '@nostr-dev-kit/ndk' import NDK, { getRelayListForUser, NDKList, NDKUser } from '@nostr-dev-kit/ndk'
import { kinds } from 'nostr-tools' import { kinds } from 'nostr-tools'
import { MuteLists } from '../types' import { MuteLists } from '../types'
import { log, LogType, npubToHex, timeout } from '../utils' import { UserProfile } from '../types/user'
import { hexToNpub, log, LogType, npubToHex, timeout } from '../utils'
export enum UserRelaysType { export enum UserRelaysType {
Read = 'readRelayUrls', Read = 'readRelayUrls',
@ -15,6 +16,7 @@ export enum UserRelaysType {
export class MetadataController { export class MetadataController {
private static instance: MetadataController private static instance: MetadataController
private ndk: NDK private ndk: NDK
private usersMetadata = new Map<string, UserProfile>()
public adminNpubs: string[] public adminNpubs: string[]
public adminRelays = new Set<string>() public adminRelays = new Set<string>()
public reportingNpub: string public reportingNpub: string
@ -82,6 +84,40 @@ export class MetadataController {
return MetadataController.instance 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<UserProfile> => {
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<UserProfile> => {
return this.findMetadata(this.adminNpubs[0])
}
public findUserRelays = async ( public findUserRelays = async (
hexKey: string, hexKey: string,
userRelaysType: UserRelaysType = UserRelaysType.Both userRelaysType: UserRelaysType = UserRelaysType.Both

View File

@ -372,6 +372,217 @@ export class RelayController {
return publishedOnRelays 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<Event | null>} - Returns a promise that resolves to the found event or null if not found.
*/
fetchEvents = async (
filter: Filter,
relayUrls: string[] = []
): Promise<Event[]> => {
const relaySet = new Set<string>()
// 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<Relay[]>((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<string>() // To keep track of event IDs and avoid duplicates
// Create a promise for each relay subscription
const subPromises = relays.map((relay) => {
return new Promise<void>((resolve) => {
// 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<Event | null>} - Returns a promise that resolves to the found event or null if not found.
*/
fetchEvent = async (
filter: Filter,
relays: string[] = []
): Promise<Event | null> => {
// 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<Event[]> => {
// 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<Event | null> => {
// 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. * Subscribes to events from multiple relays.
* *

View File

@ -1,12 +1,6 @@
import { Invoice } from '@getalby/lightning-tools' import { Invoice } from '@getalby/lightning-tools'
import NDK, {
NDKFilter,
NDKKind,
NDKRelaySet,
NDKSubscriptionCacheUsage
} from '@nostr-dev-kit/ndk'
import axios, { AxiosInstance } from 'axios' import axios, { AxiosInstance } from 'axios'
import { kinds } from 'nostr-tools' import { Filter, kinds } from 'nostr-tools'
import { requestProvider, SendPaymentResponse, WebLNProvider } from 'webln' import { requestProvider, SendPaymentResponse, WebLNProvider } from 'webln'
import { import {
isLnurlResponse, isLnurlResponse,
@ -17,6 +11,7 @@ import {
ZapRequest ZapRequest
} from '../types' } from '../types'
import { log, LogType, npubToHex } from '../utils' import { log, LogType, npubToHex } from '../utils'
import { RelayController } from './relay'
import { MetadataController, UserRelaysType } from './metadata' import { MetadataController, UserRelaysType } from './metadata'
/** /**
@ -139,7 +134,6 @@ export class ZapController {
*/ */
async pollZapReceipt( async pollZapReceipt(
paymentRequest: PaymentRequest, paymentRequest: PaymentRequest,
ndk: NDK,
pollingTimeout?: number pollingTimeout?: number
) { ) {
const { pr, ...zapRequest } = paymentRequest const { pr, ...zapRequest } = paymentRequest
@ -154,7 +148,7 @@ export class ZapController {
const cleanup = () => { const cleanup = () => {
clearTimeout(timeout) clearTimeout(timeout)
subscription.stop() subscriptions.forEach((subscription) => subscription.close())
} }
// Polling timeout // Polling timeout
@ -174,35 +168,32 @@ export class ZapController {
const relayUrls = relaysTag.slice(1) const relayUrls = relaysTag.slice(1)
// filter relay for event of kind 9735 // filter relay for event of kind 9735
const filter: NDKFilter = { const filter: Filter = {
kinds: [NDKKind.Zap], kinds: [kinds.Zap],
since: created_at since: created_at
} }
const subscription = ndk.subscribe( const subscriptions =
filter, await RelayController.getInstance().subscribeForEvents(
{ filter,
closeOnEose: false, relayUrls,
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY async (event) => {
}, // get description tag of the event
NDKRelaySet.fromRelayUrls(relayUrls, ndk, true) const description = event.tags.filter(
) (tag) => tag[0] === 'description'
)[0]
subscription.on('event', async (ndkEvent) => { // compare description tag of the event with stringified zap request
// compare description tag of the event with stringified zap request if (description[1] === zapRequestStringified) {
if (ndkEvent.tagValue('description') === zapRequestStringified) { // validate zap receipt
// validate zap receipt if (await this.validateZapReceipt(pr, event as ZapReceipt)) {
if ( cleanup()
await this.validateZapReceipt(pr, ndkEvent.rawEvent() as ZapReceipt)
) {
cleanup()
resolve(ndkEvent.rawEvent() as ZapReceipt) resolve(event as ZapReceipt)
}
}
} }
} )
})
subscription.start()
}) })
} }

View File

@ -5,4 +5,3 @@ export * from './useGames'
export * from './useMuteLists' export * from './useMuteLists'
export * from './useNSFWList' export * from './useNSFWList'
export * from './useReactions' export * from './useReactions'
export * from './useNDKContext'

View File

@ -1,87 +1,43 @@
import { import {
getRelayListForUser, MetadataController,
NDKFilter, RelayController,
NDKKind, UserRelaysType
NDKRelaySet, } from 'controllers'
NDKSubscription, import { Filter, kinds } from 'nostr-tools'
NDKSubscriptionCacheUsage import { useState } from 'react'
} from '@nostr-dev-kit/ndk'
import { UserRelaysType } from 'controllers'
import { useEffect, useState } from 'react'
import { CommentEvent, ModDetails } from 'types' import { CommentEvent, ModDetails } from 'types'
import { log, LogType } from 'utils' import { useDidMount } from './useDidMount'
import { useNDKContext } from './useNDKContext'
export const useComments = (mod: ModDetails) => { export const useComments = (mod: ModDetails) => {
const { ndk } = useNDKContext()
const [commentEvents, setCommentEvents] = useState<CommentEvent[]>([]) const [commentEvents, setCommentEvents] = useState<CommentEvent[]>([])
useEffect(() => { useDidMount(async () => {
let subscription: NDKSubscription // Define the subscription variable here for cleanup const metadataController = await MetadataController.getInstance()
const setupSubscription = async () => { const authorReadRelays = await metadataController.findUserRelays(
// Find the mod author's relays. mod.author,
const authorReadRelays = await getRelayListForUser(mod.author, ndk) UserRelaysType.Read
.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: NDKFilter = { const filter: Filter = {
kinds: [NDKKind.Text], kinds: [kinds.ShortTextNote],
'#a': [mod.aTag] '#a': [mod.aTag]
} }
subscription = ndk.subscribe( RelayController.getInstance().subscribeForEvents(
filter, filter,
{ authorReadRelays,
closeOnEose: false, (event) => {
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST
},
NDKRelaySet.fromRelayUrls(authorReadRelays, ndk, true)
)
subscription.on('event', (ndkEvent) => {
setCommentEvents((prev) => { setCommentEvents((prev) => {
if (prev.find((e) => e.id === ndkEvent.id)) { if (prev.find((e) => e.id === event.id)) {
return [...prev] return [...prev]
} }
const commentEvent: CommentEvent = { return [event, ...prev]
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 { return {
commentEvents, commentEvents,

View File

@ -1,31 +0,0 @@
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
}
}

View File

@ -1,10 +1,9 @@
import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk' import { useState, useMemo } from 'react'
import { toast } from 'react-toastify'
import { REACTIONS } from 'constants.ts' import { REACTIONS } from 'constants.ts'
import { RelayController, UserRelaysType } from 'controllers' import { RelayController, UserRelaysType } from 'controllers'
import { useAppSelector, useDidMount, useNDKContext } from 'hooks' import { useAppSelector, useDidMount } from 'hooks'
import { Event, kinds, UnsignedEvent } from 'nostr-tools' import { Event, Filter, UnsignedEvent, kinds } from 'nostr-tools'
import { useMemo, useState } from 'react'
import { toast } from 'react-toastify'
import { abbreviateNumber, log, LogType, now } from 'utils' import { abbreviateNumber, log, LogType, now } from 'utils'
type UseReactionsParams = { type UseReactionsParams = {
@ -14,15 +13,14 @@ type UseReactionsParams = {
} }
export const useReactions = (params: UseReactionsParams) => { export const useReactions = (params: UseReactionsParams) => {
const { ndk, fetchEventsFromUserRelays } = useNDKContext()
const [isReactionInProgress, setIsReactionInProgress] = useState(false) const [isReactionInProgress, setIsReactionInProgress] = useState(false)
const [isDataLoaded, setIsDataLoaded] = useState(false) const [isDataLoaded, setIsDataLoaded] = useState(false)
const [reactionEvents, setReactionEvents] = useState<NDKEvent[]>([]) const [reactionEvents, setReactionEvents] = useState<Event[]>([])
const userState = useAppSelector((state) => state.user) const userState = useAppSelector((state) => state.user)
useDidMount(() => { useDidMount(() => {
const filter: NDKFilter = { const filter: Filter = {
kinds: [kinds.Reaction] kinds: [kinds.Reaction]
} }
@ -32,7 +30,8 @@ export const useReactions = (params: UseReactionsParams) => {
filter['#e'] = [params.eTag] filter['#e'] = [params.eTag]
} }
fetchEventsFromUserRelays(filter, params.pubkey, UserRelaysType.Read) RelayController.getInstance()
.fetchEventsFromUserRelays(filter, params.pubkey, UserRelaysType.Read)
.then((events) => { .then((events) => {
setReactionEvents(events) setReactionEvents(events)
}) })
@ -119,7 +118,7 @@ export const useReactions = (params: UseReactionsParams) => {
if (!signedEvent) return if (!signedEvent) return
setReactionEvents((prev) => [...prev, new NDKEvent(ndk, signedEvent)]) setReactionEvents((prev) => [...prev, signedEvent])
const publishedOnRelays = await RelayController.getInstance().publish( const publishedOnRelays = await RelayController.getInstance().publish(
signedEvent as Event, signedEvent as Event,

View File

@ -7,12 +7,7 @@ import { Link } from 'react-router-dom'
import { Banner } from '../components/Banner' import { Banner } from '../components/Banner'
import { ZapPopUp } from '../components/Zap' import { ZapPopUp } from '../components/Zap'
import { MetadataController } from '../controllers' import { MetadataController } from '../controllers'
import { import { useAppDispatch, useAppSelector, useDidMount } from '../hooks'
useAppDispatch,
useAppSelector,
useDidMount,
useNDKContext
} from '../hooks'
import { appRoutes } from '../routes' import { appRoutes } from '../routes'
import { setAuth, setUser } from '../store/reducers/user' import { setAuth, setUser } from '../store/reducers/user'
import mainStyles from '../styles//main.module.scss' import mainStyles from '../styles//main.module.scss'
@ -22,7 +17,6 @@ import { npubToHex } from '../utils'
export const Header = () => { export const Header = () => {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { findMetadata } = useNDKContext()
const userState = useAppSelector((state) => state.user) const userState = useAppSelector((state) => state.user)
useEffect(() => { useEffect(() => {
@ -47,21 +41,23 @@ export const Header = () => {
pubkey: npubToHex(npub)! pubkey: npubToHex(npub)!
}) })
) )
findMetadata(npub).then((userProfile) => { MetadataController.getInstance().then((metadataController) => {
if (userProfile) { metadataController.findMetadata(npub).then((userProfile) => {
dispatch( if (userProfile) {
setUser({ dispatch(
npub, setUser({
pubkey: npubToHex(npub)!, npub,
...userProfile pubkey: npubToHex(npub)!,
}) ...userProfile
) })
} )
}
})
}) })
} }
} }
}) })
}, [dispatch, findMetadata]) }, [dispatch])
const handleLogin = () => { const handleLogin = () => {
launchNostrLoginDialog() launchNostrLoginDialog()
@ -361,14 +357,8 @@ const RegisterButtonWithDialog = () => {
</label> </label>
<p className='labelDescriptionMain'> <p className='labelDescriptionMain'>
Once you create your "account" on any of these ( Once you create your "account" on any of these (
<a <a href="https://video.nostr.build/765aa9bf16dd58bca701efee2572f7e77f29b2787cddd2bee8bbbdea35798153.mp4" target="blank_">Here's a quick video guide</a>
href='https://video.nostr.build/765aa9bf16dd58bca701efee2572f7e77f29b2787cddd2bee8bbbdea35798153.mp4' ), come back and click login, then sign-in with extension.
target='blank_'
>
Here's a quick video guide
</a>
), come back and click login, then sign-in with
extension.
</p> </p>
</div> </div>
<a <a

View File

@ -7,15 +7,12 @@ import 'react-toastify/dist/ReactToastify.css'
import App from './App.tsx' import App from './App.tsx'
import './index.css' import './index.css'
import { store } from './store/index.ts' import { store } from './store/index.ts'
import { NDKContextProvider } from 'contexts/NDKContext.tsx'
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<Provider store={store}> <Provider store={store}>
<HashRouter> <HashRouter>
<NDKContextProvider> <App />
<App />
</NDKContextProvider>
<ToastContainer /> <ToastContainer />
</HashRouter> </HashRouter>
</Provider> </Provider>

View File

@ -1,21 +1,20 @@
import { import { LoadingSpinner } from 'components/LoadingSpinner'
NDKFilter,
NDKKind,
NDKSubscriptionCacheUsage
} from '@nostr-dev-kit/ndk'
import { ModCard } from 'components/ModCard' import { ModCard } from 'components/ModCard'
import { ModFilter } from 'components/ModsFilter' import { ModFilter } from 'components/ModsFilter'
import { PaginationWithPageNumbers } from 'components/Pagination' import { PaginationWithPageNumbers } from 'components/Pagination'
import { MAX_MODS_PER_PAGE, T_TAG_VALUE } from 'constants.ts' import { MAX_MODS_PER_PAGE, T_TAG_VALUE } from 'constants.ts'
import { RelayController } from 'controllers'
import { import {
useAppSelector, useAppSelector,
useFilteredMods, useFilteredMods,
useMuteLists, useMuteLists,
useNDKContext,
useNSFWList useNSFWList
} from 'hooks' } from 'hooks'
import { useEffect, useState } from 'react' import { Filter, kinds } from 'nostr-tools'
import { Subscription } from 'nostr-tools/abstract-relay'
import { useEffect, useRef, useState } from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { toast } from 'react-toastify'
import { import {
FilterOptions, FilterOptions,
ModDetails, ModDetails,
@ -23,12 +22,11 @@ import {
NSFWFilter, NSFWFilter,
SortBy SortBy
} from 'types' } from 'types'
import { extractModData, isModDataComplete } from 'utils' import { extractModData, isModDataComplete, log, LogType } from 'utils'
export const GamePage = () => { export const GamePage = () => {
const params = useParams() const params = useParams()
const { name: gameName } = params const { name: gameName } = params
const { ndk } = useNDKContext()
const muteLists = useMuteLists() const muteLists = useMuteLists()
const nsfwList = useNSFWList() const nsfwList = useNSFWList()
@ -40,6 +38,8 @@ export const GamePage = () => {
}) })
const [mods, setMods] = useState<ModDetails[]>([]) const [mods, setMods] = useState<ModDetails[]>([])
const hasEffectRun = useRef(false)
const [isSubscribing, setIsSubscribing] = useState(false)
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
const userState = useAppSelector((state) => state.user) const userState = useAppSelector((state) => state.user)
@ -66,40 +66,57 @@ export const GamePage = () => {
} }
useEffect(() => { useEffect(() => {
const filter: NDKFilter = { if (hasEffectRun.current) {
kinds: [NDKKind.Classified], return
}
hasEffectRun.current = true // Set it so the effect doesn't run again
const filter: Filter = {
kinds: [kinds.ClassifiedListing],
'#t': [T_TAG_VALUE] '#t': [T_TAG_VALUE]
} }
const subscription = ndk.subscribe(filter, { setIsSubscribing(true)
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL,
closeOnEose: true
})
subscription.on('event', (ndkEvent) => { let subscriptions: Subscription[] = []
if (isModDataComplete(ndkEvent)) {
const mod = extractModData(ndkEvent)
if (mod.game === gameName)
setMods((prev) => {
if (prev.find((e) => e.aTag === mod.aTag)) return [...prev]
return [...prev, mod] 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)
})
subscription.start() // Cleanup function to stop all subscriptions
// Cleanup function to stop subscription
return () => { return () => {
subscription.stop() subscriptions.forEach((sub) => sub.close()) // close each subscription
} }
}, [gameName, ndk]) }, [gameName])
if (!gameName) return null if (!gameName) return null
return ( return (
<> <>
{isSubscribing && (
<LoadingSpinner desc='Subscribing to relays for mods' />
)}
<div className='InnerBodyMain'> <div className='InnerBodyMain'>
<div className='ContainerMain'> <div className='ContainerMain'>
<div className='IBMSecMainGroup IBMSecMainGroupAlt'> <div className='IBMSecMainGroup IBMSecMainGroupAlt'>

View File

@ -1,6 +1,6 @@
import { PaginationWithPageNumbers } from 'components/Pagination' import { PaginationWithPageNumbers } from 'components/Pagination'
import { MAX_GAMES_PER_PAGE } from 'constants.ts' import { MAX_GAMES_PER_PAGE } from 'constants.ts'
import { useDidMount, useGames, useNDKContext } from 'hooks' import { useDidMount, useGames } from 'hooks'
import { useMemo, useRef, useState } from 'react' import { useMemo, useRef, useState } from 'react'
import { GameCard } from '../components/GameCard' import { GameCard } from '../components/GameCard'
import '../styles/pagination.css' import '../styles/pagination.css'
@ -8,10 +8,10 @@ import '../styles/search.css'
import '../styles/styles.css' import '../styles/styles.css'
import { createSearchParams, useNavigate } from 'react-router-dom' import { createSearchParams, useNavigate } from 'react-router-dom'
import { appRoutes } from 'routes' import { appRoutes } from 'routes'
import { fetchMods } from 'utils'
export const GamesPage = () => { export const GamesPage = () => {
const navigate = useNavigate() const navigate = useNavigate()
const { fetchMods } = useNDKContext()
const searchTermRef = useRef<HTMLInputElement>(null) const searchTermRef = useRef<HTMLInputElement>(null)
const games = useGames() const games = useGames()
const [gamesWithMods, setGamesWithMods] = useState<string[]>([]) const [gamesWithMods, setGamesWithMods] = useState<string[]>([])

View File

@ -1,4 +1,4 @@
import { nip19 } from 'nostr-tools' import { Filter, nip19 } from 'nostr-tools'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { A11y, Autoplay, Navigation, Pagination } from 'swiper/modules' import { A11y, Autoplay, Navigation, Pagination } from 'swiper/modules'
@ -7,23 +7,23 @@ import { BlogCard } from '../components/BlogCard'
import { GameCard } from '../components/GameCard' import { GameCard } from '../components/GameCard'
import { ModCard } from '../components/ModCard' import { ModCard } from '../components/ModCard'
import { LANDING_PAGE_DATA } from '../constants' import { LANDING_PAGE_DATA } from '../constants'
import { import { RelayController } from '../controllers'
useDidMount, import { useDidMount, useGames, useMuteLists, useNSFWList } from '../hooks'
useGames,
useMuteLists,
useNDKContext,
useNSFWList
} from '../hooks'
import { appRoutes, getModPageRoute } from '../routes' import { appRoutes, getModPageRoute } from '../routes'
import { ModDetails } from '../types' import { ModDetails } from '../types'
import { extractModData, handleModImageError, log, LogType } from '../utils' import {
extractModData,
fetchMods,
handleModImageError,
log,
LogType
} from '../utils'
import '../styles/cardLists.css' import '../styles/cardLists.css'
import '../styles/SimpleSlider.css' import '../styles/SimpleSlider.css'
import '../styles/styles.css' import '../styles/styles.css'
// Import Swiper styles // Import Swiper styles
import { NDKFilter } from '@nostr-dev-kit/ndk'
import 'swiper/css' import 'swiper/css'
import 'swiper/css/navigation' import 'swiper/css/navigation'
import 'swiper/css/pagination' import 'swiper/css/pagination'
@ -146,23 +146,23 @@ type SlideContentProps = {
const SlideContent = ({ naddr }: SlideContentProps) => { const SlideContent = ({ naddr }: SlideContentProps) => {
const navigate = useNavigate() const navigate = useNavigate()
const { fetchEvent } = useNDKContext()
const [mod, setMod] = useState<ModDetails>() const [mod, setMod] = useState<ModDetails>()
useDidMount(() => { useDidMount(() => {
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`) const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
const { identifier, kind, pubkey, relays = [] } = decoded.data const { identifier, kind, pubkey, relays = [] } = decoded.data
const ndkFilter: NDKFilter = { const filter: Filter = {
'#a': [identifier], '#a': [identifier],
authors: [pubkey], authors: [pubkey],
kinds: [kind] kinds: [kind]
} }
fetchEvent(ndkFilter, relays) RelayController.getInstance()
.then((ndkEvent) => { .fetchEvent(filter, relays)
if (ndkEvent) { .then((event) => {
const extracted = extractModData(ndkEvent) if (event) {
const extracted = extractModData(event)
setMod(extracted) setMod(extracted)
} }
}) })
@ -220,22 +220,21 @@ type DisplayModProps = {
const DisplayMod = ({ naddr }: DisplayModProps) => { const DisplayMod = ({ naddr }: DisplayModProps) => {
const [mod, setMod] = useState<ModDetails>() const [mod, setMod] = useState<ModDetails>()
const { fetchEvent } = useNDKContext()
useDidMount(() => { useDidMount(() => {
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`) const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
const { identifier, kind, pubkey, relays = [] } = decoded.data const { identifier, kind, pubkey, relays = [] } = decoded.data
const ndkFilter: NDKFilter = { const filter: Filter = {
'#a': [identifier], '#a': [identifier],
authors: [pubkey], authors: [pubkey],
kinds: [kind] kinds: [kind]
} }
fetchEvent(ndkFilter, relays) RelayController.getInstance()
.then((ndkEvent) => { .fetchEvent(filter, relays)
if (ndkEvent) { .then((event) => {
const extracted = extractModData(ndkEvent) if (event) {
const extracted = extractModData(event)
setMod(extracted) setMod(extracted)
} }
}) })
@ -256,7 +255,6 @@ const DisplayMod = ({ naddr }: DisplayModProps) => {
const DisplayLatestMods = () => { const DisplayLatestMods = () => {
const navigate = useNavigate() const navigate = useNavigate()
const { fetchMods } = useNDKContext()
const [isFetchingLatestMods, setIsFetchingLatestMods] = useState(true) const [isFetchingLatestMods, setIsFetchingLatestMods] = useState(true)
const [latestMods, setLatestMods] = useState<ModDetails[]>([]) const [latestMods, setLatestMods] = useState<ModDetails[]>([])
@ -265,7 +263,8 @@ const DisplayLatestMods = () => {
useDidMount(() => { useDidMount(() => {
fetchMods({ source: window.location.host }) fetchMods({ source: window.location.host })
.then((mods) => { .then((res) => {
const mods = res.sort((a, b) => b.published_at - a.published_at)
setLatestMods(mods) setLatestMods(mods)
}) })
.finally(() => { .finally(() => {

View File

@ -1,18 +1,21 @@
import { NDKFilter, NDKKind } from '@nostr-dev-kit/ndk'
import Link from '@tiptap/extension-link' import Link from '@tiptap/extension-link'
import { EditorContent, useEditor } from '@tiptap/react' import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit' import StarterKit from '@tiptap/starter-kit'
import { formatDate } from 'date-fns' import { formatDate } from 'date-fns'
import FsLightbox from 'fslightbox-react' import FsLightbox from 'fslightbox-react'
import { nip19, UnsignedEvent } from 'nostr-tools' import { Filter, kinds, nip19, UnsignedEvent } from 'nostr-tools'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Link as ReactRouterLink, useParams } from 'react-router-dom' import { Link as ReactRouterLink, useParams } from 'react-router-dom'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { BlogCard } from '../../components/BlogCard' import { BlogCard } from '../../components/BlogCard'
import { LoadingSpinner } from '../../components/LoadingSpinner' import { LoadingSpinner } from '../../components/LoadingSpinner'
import { ProfileSection } from '../../components/ProfileSection' import { ProfileSection } from '../../components/ProfileSection'
import { MetadataController, UserRelaysType } from '../../controllers' import {
import { useAppSelector, useDidMount, useNDKContext } from '../../hooks' MetadataController,
RelayController,
UserRelaysType
} from '../../controllers'
import { useAppSelector, useDidMount } from '../../hooks'
import { getGamePageRoute, getModsEditPageRoute } from '../../routes' import { getGamePageRoute, getModsEditPageRoute } from '../../routes'
import '../../styles/comments.css' import '../../styles/comments.css'
import '../../styles/downloads.css' import '../../styles/downloads.css'
@ -44,7 +47,6 @@ import { Zap } from './internal/zap'
export const ModPage = () => { export const ModPage = () => {
const { naddr } = useParams() const { naddr } = useParams()
const { fetchEvent } = useNDKContext()
const [modData, setModData] = useState<ModDetails>() const [modData, setModData] = useState<ModDetails>()
const [isFetching, setIsFetching] = useState(true) const [isFetching, setIsFetching] = useState(true)
const [commentCount, setCommentCount] = useState(0) const [commentCount, setCommentCount] = useState(0)
@ -54,13 +56,14 @@ export const ModPage = () => {
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`) const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
const { identifier, kind, pubkey, relays = [] } = decoded.data const { identifier, kind, pubkey, relays = [] } = decoded.data
const filter: NDKFilter = { const filter: Filter = {
'#a': [identifier], '#a': [identifier],
authors: [pubkey], authors: [pubkey],
kinds: [kind] kinds: [kind]
} }
fetchEvent(filter, relays) RelayController.getInstance()
.fetchEvent(filter, relays)
.then((event) => { .then((event) => {
if (event) { if (event) {
const extracted = extractModData(event) const extracted = extractModData(event)
@ -211,8 +214,6 @@ type GameProps = {
} }
const Game = ({ naddr, game, author, aTag }: GameProps) => { const Game = ({ naddr, game, author, aTag }: GameProps) => {
const { fetchEventFromUserRelays } = useNDKContext()
const userState = useAppSelector((state) => state.user) const userState = useAppSelector((state) => state.user)
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
@ -224,54 +225,56 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => {
if (userState.auth && userState.user?.pubkey) { if (userState.auth && userState.user?.pubkey) {
const pubkey = userState.user.pubkey as string const pubkey = userState.user.pubkey as string
const muteListFilter: NDKFilter = { const muteListFilter: Filter = {
kinds: [NDKKind.MuteList], kinds: [kinds.Mutelist],
authors: [pubkey] authors: [pubkey]
} }
fetchEventFromUserRelays( RelayController.getInstance()
muteListFilter, .fetchEventFromUserRelays(muteListFilter, pubkey, UserRelaysType.Write)
pubkey, .then((event) => {
UserRelaysType.Write if (event) {
).then((event) => { // get a list of tags
if (event) { const tags = event.tags
// get a list of tags const blocked =
const tags = event.tags tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !==
const blocked = -1
tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== -1
setIsBlocked(blocked) setIsBlocked(blocked)
} }
}) })
if ( if (
userState.user.npub && userState.user.npub &&
userState.user.npub === import.meta.env.VITE_REPORTING_NPUB userState.user.npub === import.meta.env.VITE_REPORTING_NPUB
) { ) {
const nsfwListFilter: NDKFilter = { const nsfwListFilter: Filter = {
kinds: [NDKKind.ArticleCurationSet], kinds: [kinds.Curationsets],
authors: [pubkey], authors: [pubkey],
'#d': ['nsfw'] '#d': ['nsfw']
} }
fetchEventFromUserRelays( RelayController.getInstance()
nsfwListFilter, .fetchEventFromUserRelays(
pubkey, nsfwListFilter,
UserRelaysType.Write pubkey,
).then((event) => { UserRelaysType.Write
if (event) { )
// get a list of tags .then((event) => {
const tags = event.tags if (event) {
const existsInNSFWList = // get a list of tags
tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== const tags = event.tags
-1 const existsInNSFWList =
tags.findIndex(
(item) => item[0] === 'a' && item[1] === aTag
) !== -1
setIsAddedToNSFW(existsInNSFWList) setIsAddedToNSFW(existsInNSFWList)
} }
}) })
} }
} }
}, [userState, aTag, fetchEventFromUserRelays]) }, [userState, aTag])
const handleBlock = async () => { const handleBlock = async () => {
let hexPubkey: string let hexPubkey: string
@ -295,17 +298,18 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => {
// Define the event filter to search for the user's mute list events. // 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. // We look for events of a specific kind (Mutelist) authored by the given hexPubkey.
const filter: NDKFilter = { const filter: Filter = {
kinds: [NDKKind.MuteList], kinds: [kinds.Mutelist],
authors: [hexPubkey] authors: [hexPubkey]
} }
// Fetch the mute list event from the relays. This returns the event containing the 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 fetchEventFromUserRelays( const muteListEvent =
filter, await RelayController.getInstance().fetchEventFromUserRelays(
hexPubkey, filter,
UserRelaysType.Write hexPubkey,
) UserRelaysType.Write
)
let unsignedEvent: UnsignedEvent let unsignedEvent: UnsignedEvent
@ -325,7 +329,7 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => {
unsignedEvent = { unsignedEvent = {
pubkey: muteListEvent.pubkey, pubkey: muteListEvent.pubkey,
kind: NDKKind.MuteList, kind: muteListEvent.kind,
content: muteListEvent.content, content: muteListEvent.content,
created_at: now(), created_at: now(),
tags: [...tags] tags: [...tags]
@ -333,7 +337,7 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => {
} else { } else {
unsignedEvent = { unsignedEvent = {
pubkey: hexPubkey, pubkey: hexPubkey,
kind: NDKKind.MuteList, kind: kinds.Mutelist,
content: '', content: '',
created_at: now(), created_at: now(),
tags: [['a', aTag]] tags: [['a', aTag]]
@ -352,8 +356,8 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => {
const handleUnblock = async () => { const handleUnblock = async () => {
const pubkey = userState.user?.pubkey as string const pubkey = userState.user?.pubkey as string
const filter: NDKFilter = { const filter: Filter = {
kinds: [NDKKind.MuteList], kinds: [kinds.Mutelist],
authors: [pubkey] authors: [pubkey]
} }
@ -361,11 +365,12 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => {
setLoadingSpinnerDesc(`Finding user's mute list`) setLoadingSpinnerDesc(`Finding user's mute list`)
// Fetch the mute list event from the relays. This returns the event containing the 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 fetchEventFromUserRelays( const muteListEvent =
filter, await RelayController.getInstance().fetchEventFromUserRelays(
pubkey, filter,
UserRelaysType.Write pubkey,
) UserRelaysType.Write
)
if (!muteListEvent) { if (!muteListEvent) {
toast.error(`Couldn't get user's mute list event from relays`) toast.error(`Couldn't get user's mute list event from relays`)
@ -376,7 +381,7 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => {
const unsignedEvent: UnsignedEvent = { const unsignedEvent: UnsignedEvent = {
pubkey: muteListEvent.pubkey, pubkey: muteListEvent.pubkey,
kind: NDKKind.MuteList, kind: muteListEvent.kind,
content: muteListEvent.content, content: muteListEvent.content,
created_at: now(), created_at: now(),
tags: tags.filter((item) => item[0] !== 'a' || item[1] !== aTag) tags: tags.filter((item) => item[0] !== 'a' || item[1] !== aTag)
@ -396,8 +401,8 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => {
if (!pubkey) return if (!pubkey) return
const filter: NDKFilter = { const filter: Filter = {
kinds: [NDKKind.ArticleCurationSet], kinds: [kinds.Curationsets],
authors: [pubkey], authors: [pubkey],
'#d': ['nsfw'] '#d': ['nsfw']
} }
@ -405,11 +410,12 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => {
setIsLoading(true) setIsLoading(true)
setLoadingSpinnerDesc('Finding NSFW list') setLoadingSpinnerDesc('Finding NSFW list')
const nsfwListEvent = await fetchEventFromUserRelays( const nsfwListEvent =
filter, await RelayController.getInstance().fetchEventFromUserRelays(
pubkey, filter,
UserRelaysType.Write pubkey,
) UserRelaysType.Write
)
let unsignedEvent: UnsignedEvent let unsignedEvent: UnsignedEvent
@ -429,7 +435,7 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => {
unsignedEvent = { unsignedEvent = {
pubkey: nsfwListEvent.pubkey, pubkey: nsfwListEvent.pubkey,
kind: NDKKind.ArticleCurationSet, kind: nsfwListEvent.kind,
content: nsfwListEvent.content, content: nsfwListEvent.content,
created_at: now(), created_at: now(),
tags: [...tags] tags: [...tags]
@ -437,7 +443,7 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => {
} else { } else {
unsignedEvent = { unsignedEvent = {
pubkey: pubkey, pubkey: pubkey,
kind: NDKKind.ArticleCurationSet, kind: kinds.Curationsets,
content: '', content: '',
created_at: now(), created_at: now(),
tags: [ tags: [
@ -459,8 +465,8 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => {
const handleUnblockNSFW = async () => { const handleUnblockNSFW = async () => {
const pubkey = userState.user?.pubkey as string const pubkey = userState.user?.pubkey as string
const filter: NDKFilter = { const filter: Filter = {
kinds: [NDKKind.ArticleCurationSet], kinds: [kinds.Curationsets],
authors: [pubkey], authors: [pubkey],
'#d': ['nsfw'] '#d': ['nsfw']
} }
@ -468,11 +474,12 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => {
setIsLoading(true) setIsLoading(true)
setLoadingSpinnerDesc('Finding NSFW list') setLoadingSpinnerDesc('Finding NSFW list')
const nsfwListEvent = await fetchEventFromUserRelays( const nsfwListEvent =
filter, await RelayController.getInstance().fetchEventFromUserRelays(
pubkey, filter,
UserRelaysType.Write pubkey,
) UserRelaysType.Write
)
if (!nsfwListEvent) { if (!nsfwListEvent) {
toast.error(`Couldn't get nsfw list event from relays`) toast.error(`Couldn't get nsfw list event from relays`)
@ -483,7 +490,7 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => {
const unsignedEvent: UnsignedEvent = { const unsignedEvent: UnsignedEvent = {
pubkey: nsfwListEvent.pubkey, pubkey: nsfwListEvent.pubkey,
kind: NDKKind.ArticleCurationSet, kind: nsfwListEvent.kind,
content: nsfwListEvent.content, content: nsfwListEvent.content,
created_at: now(), created_at: now(),
tags: tags.filter((item) => item[0] !== 'a' || item[1] !== aTag) tags: tags.filter((item) => item[0] !== 'a' || item[1] !== aTag)
@ -660,7 +667,6 @@ type ReportPopupProps = {
} }
const ReportPopup = ({ aTag, handleClose }: ReportPopupProps) => { const ReportPopup = ({ aTag, handleClose }: ReportPopupProps) => {
const { fetchEventFromUserRelays } = useNDKContext()
const userState = useAppSelector((state) => state.user) const userState = useAppSelector((state) => state.user)
const [selectedOptions, setSelectedOptions] = useState({ const [selectedOptions, setSelectedOptions] = useState({
actuallyCP: false, actuallyCP: false,
@ -714,17 +720,18 @@ const ReportPopup = ({ aTag, handleClose }: ReportPopupProps) => {
setLoadingSpinnerDesc(`Finding user's mute list`) setLoadingSpinnerDesc(`Finding user's mute list`)
// Define the event filter to search for the user's mute list events. // 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. // We look for events of a specific kind (Mutelist) authored by the given hexPubkey.
const filter: NDKFilter = { const filter: Filter = {
kinds: [NDKKind.MuteList], kinds: [kinds.Mutelist],
authors: [hexPubkey] authors: [hexPubkey]
} }
// Fetch the mute list event from the relays. This returns the event containing the 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 fetchEventFromUserRelays( const muteListEvent =
filter, await RelayController.getInstance().fetchEventFromUserRelays(
hexPubkey, filter,
UserRelaysType.Write hexPubkey,
) UserRelaysType.Write
)
let unsignedEvent: UnsignedEvent let unsignedEvent: UnsignedEvent
@ -743,7 +750,7 @@ const ReportPopup = ({ aTag, handleClose }: ReportPopupProps) => {
unsignedEvent = { unsignedEvent = {
pubkey: muteListEvent.pubkey, pubkey: muteListEvent.pubkey,
kind: NDKKind.MuteList, kind: muteListEvent.kind,
content: muteListEvent.content, content: muteListEvent.content,
created_at: now(), created_at: now(),
tags: [...tags] tags: [...tags]
@ -751,7 +758,7 @@ const ReportPopup = ({ aTag, handleClose }: ReportPopupProps) => {
} else { } else {
unsignedEvent = { unsignedEvent = {
pubkey: hexPubkey, pubkey: hexPubkey,
kind: NDKKind.MuteList, kind: kinds.Mutelist,
content: '', content: '',
created_at: now(), created_at: now(),
tags: [['a', aTag]] tags: [['a', aTag]]

View File

@ -5,7 +5,7 @@ import {
UserRelaysType UserRelaysType
} from 'controllers' } from 'controllers'
import { formatDate } from 'date-fns' import { formatDate } from 'date-fns'
import { useAppSelector, useDidMount, useNDKContext, useReactions } from 'hooks' import { useAppSelector, useDidMount, useReactions } from 'hooks'
import { useComments } from 'hooks/useComments' import { useComments } from 'hooks/useComments'
import { Event, kinds, nip19, UnsignedEvent } from 'nostr-tools' import { Event, kinds, nip19, UnsignedEvent } from 'nostr-tools'
import React, { import React, {
@ -322,11 +322,11 @@ const Filter = React.memo(
) )
const Comment = (props: CommentEvent) => { const Comment = (props: CommentEvent) => {
const { findMetadata } = useNDKContext()
const [profile, setProfile] = useState<UserProfile>() const [profile, setProfile] = useState<UserProfile>()
useDidMount(() => { useDidMount(async () => {
findMetadata(props.pubkey).then((res) => { const metadataController = await MetadataController.getInstance()
metadataController.findMetadata(props.pubkey).then((res) => {
setProfile(res) setProfile(res)
}) })
}) })

View File

@ -9,7 +9,6 @@ import {
useAppSelector, useAppSelector,
useFilteredMods, useFilteredMods,
useMuteLists, useMuteLists,
useNDKContext,
useNSFWList useNSFWList
} from '../hooks' } from '../hooks'
import { appRoutes } from '../routes' import { appRoutes } from '../routes'
@ -24,9 +23,9 @@ import {
NSFWFilter, NSFWFilter,
SortBy SortBy
} from '../types' } from '../types'
import { fetchMods } from '../utils'
export const ModsPage = () => { export const ModsPage = () => {
const { fetchMods } = useNDKContext()
const [isFetching, setIsFetching] = useState(false) const [isFetching, setIsFetching] = useState(false)
const [mods, setMods] = useState<ModDetails[]>([]) const [mods, setMods] = useState<ModDetails[]>([])
const [filterOptions, setFilterOptions] = useState<FilterOptions>({ const [filterOptions, setFilterOptions] = useState<FilterOptions>({
@ -51,7 +50,7 @@ export const ModsPage = () => {
.finally(() => { .finally(() => {
setIsFetching(false) setIsFetching(false)
}) })
}, [filterOptions.source, fetchMods]) }, [filterOptions.source])
const handleNext = useCallback(() => { const handleNext = useCallback(() => {
setIsFetching(true) setIsFetching(true)
@ -70,7 +69,7 @@ export const ModsPage = () => {
.finally(() => { .finally(() => {
setIsFetching(false) setIsFetching(false)
}) })
}, [filterOptions.source, mods, fetchMods]) }, [filterOptions.source, mods])
const handlePrev = useCallback(() => { const handlePrev = useCallback(() => {
setIsFetching(true) setIsFetching(true)
@ -88,7 +87,7 @@ export const ModsPage = () => {
.finally(() => { .finally(() => {
setIsFetching(false) setIsFetching(false)
}) })
}, [filterOptions.source, mods, fetchMods]) }, [filterOptions.source, mods])
const filteredModList = useFilteredMods( const filteredModList = useFilteredMods(
mods, mods,

View File

@ -1,11 +1,4 @@
import { import { NDKEvent, NDKUserProfile, profileFromEvent } from '@nostr-dev-kit/ndk'
NDKEvent,
NDKFilter,
NDKKind,
NDKSubscriptionCacheUsage,
NDKUserProfile,
profileFromEvent
} from '@nostr-dev-kit/ndk'
import { ErrorBoundary } from 'components/ErrorBoundary' import { ErrorBoundary } from 'components/ErrorBoundary'
import { GameCard } from 'components/GameCard' import { GameCard } from 'components/GameCard'
import { LoadingSpinner } from 'components/LoadingSpinner' import { LoadingSpinner } from 'components/LoadingSpinner'
@ -18,14 +11,16 @@ import {
MAX_MODS_PER_PAGE, MAX_MODS_PER_PAGE,
T_TAG_VALUE T_TAG_VALUE
} from 'constants.ts' } from 'constants.ts'
import { RelayController } from 'controllers'
import { import {
useAppSelector, useAppSelector,
useFilteredMods, useFilteredMods,
useGames, useGames,
useMuteLists, useMuteLists,
useNDKContext,
useNSFWList useNSFWList
} from 'hooks' } from 'hooks'
import { Filter, kinds } from 'nostr-tools'
import { Subscription } from 'nostr-tools/abstract-relay'
import React, { import React, {
Dispatch, Dispatch,
SetStateAction, SetStateAction,
@ -35,6 +30,7 @@ import React, {
useState useState
} from 'react' } from 'react'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { toast } from 'react-toastify'
import { import {
FilterOptions, FilterOptions,
ModDetails, ModDetails,
@ -271,38 +267,56 @@ const ModsResult = ({
muteLists, muteLists,
nsfwList nsfwList
}: ModsResultProps) => { }: ModsResultProps) => {
const { ndk } = useNDKContext() const hasEffectRun = useRef(false)
const [isSubscribing, setIsSubscribing] = useState(false)
const [mods, setMods] = useState<ModDetails[]>([]) const [mods, setMods] = useState<ModDetails[]>([])
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const userState = useAppSelector((state) => state.user) const userState = useAppSelector((state) => state.user)
useEffect(() => { useEffect(() => {
const filter: NDKFilter = { if (hasEffectRun.current) {
kinds: [NDKKind.Classified], return
}
hasEffectRun.current = true // Set it so the effect doesn't run again
const filter: Filter = {
kinds: [kinds.ClassifiedListing],
'#t': [T_TAG_VALUE] '#t': [T_TAG_VALUE]
} }
const subscription = ndk.subscribe(filter, { setIsSubscribing(true)
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL,
closeOnEose: true
})
subscription.on('event', (ndkEvent) => { let subscriptions: Subscription[] = []
if (isModDataComplete(ndkEvent)) {
const mod = extractModData(ndkEvent)
setMods((prev) => {
if (prev.find((e) => e.aTag === mod.aTag)) return [...prev]
return [...prev, mod] 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)
})
// Cleanup function to stop all subscriptions // Cleanup function to stop all subscriptions
return () => { return () => {
subscription.stop() subscriptions.forEach((sub) => sub.close()) // close each subscription
} }
}, [ndk]) }, [])
useEffect(() => { useEffect(() => {
setPage(1) setPage(1)
@ -343,6 +357,9 @@ const ModsResult = ({
return ( return (
<> <>
{isSubscribing && (
<LoadingSpinner desc='Subscribing to relays for mods' />
)}
<div className='IBMSecMain IBMSMListWrapper'> <div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMList'> <div className='IBMSMList'>
{filteredModList {filteredModList
@ -376,7 +393,6 @@ const UsersResult = ({
moderationFilter, moderationFilter,
muteLists muteLists
}: UsersResultProps) => { }: UsersResultProps) => {
const { fetchEvents } = useNDKContext()
const [isFetching, setIsFetching] = useState(false) const [isFetching, setIsFetching] = useState(false)
const [profiles, setProfiles] = useState<NDKUserProfile[]>([]) const [profiles, setProfiles] = useState<NDKUserProfile[]>([])
@ -386,13 +402,14 @@ const UsersResult = ({
if (searchTerm === '') { if (searchTerm === '') {
setProfiles([]) setProfiles([])
} else { } else {
const filter: NDKFilter = { const filter: Filter = {
kinds: [NDKKind.Metadata], kinds: [kinds.Metadata],
search: searchTerm search: searchTerm
} }
setIsFetching(true) setIsFetching(true)
fetchEvents(filter, ['wss://purplepag.es', 'wss://user.kindpag.es']) RelayController.getInstance()
.fetchEvents(filter, ['wss://purplepag.es', 'wss://user.kindpag.es'])
.then((events) => { .then((events) => {
const results = events.map((event) => { const results = events.map((event) => {
const ndkEvent = new NDKEvent(undefined, event) const ndkEvent = new NDKEvent(undefined, event)
@ -408,7 +425,7 @@ const UsersResult = ({
setIsFetching(false) setIsFetching(false)
}) })
} }
}, [searchTerm, fetchEvents]) }, [searchTerm])
const filteredProfiles = useMemo(() => { const filteredProfiles = useMemo(() => {
let filtered = [...profiles] let filtered = [...profiles]

View File

@ -1,22 +1,21 @@
import { NDKFilter } from '@nostr-dev-kit/ndk'
import { nip19 } from 'nostr-tools'
import { useState } from 'react'
import { useLocation, useParams } from 'react-router-dom' import { useLocation, useParams } from 'react-router-dom'
import { toast } from 'react-toastify'
import { LoadingSpinner } from '../components/LoadingSpinner'
import { ModForm } from '../components/ModForm' import { ModForm } from '../components/ModForm'
import { ProfileSection } from '../components/ProfileSection' import { ProfileSection } from '../components/ProfileSection'
import { useAppSelector, useDidMount, useNDKContext } from '../hooks'
import '../styles/innerPage.css' import '../styles/innerPage.css'
import '../styles/styles.css' import '../styles/styles.css'
import '../styles/write.css' import '../styles/write.css'
import { ModDetails } from '../types' import { Filter, nip19 } from 'nostr-tools'
import { RelayController } from '../controllers'
import { extractModData, log, LogType } from '../utils' 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'
export const SubmitModPage = () => { export const SubmitModPage = () => {
const location = useLocation() const location = useLocation()
const { naddr } = useParams() const { naddr } = useParams()
const { fetchEvent } = useNDKContext()
const [modData, setModData] = useState<ModDetails>() const [modData, setModData] = useState<ModDetails>()
const [isFetching, setIsFetching] = useState(false) const [isFetching, setIsFetching] = useState(false)
@ -31,15 +30,15 @@ export const SubmitModPage = () => {
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`) const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
const { identifier, kind, pubkey, relays = [] } = decoded.data const { identifier, kind, pubkey, relays = [] } = decoded.data
const filter: NDKFilter = { const filter: Filter = {
'#a': [identifier], '#a': [identifier],
authors: [pubkey], authors: [pubkey],
kinds: [kind] kinds: [kind]
} }
setIsFetching(true) setIsFetching(true)
RelayController.getInstance()
fetchEvent(filter, relays) .fetchEvent(filter, relays)
.then((event) => { .then((event) => {
if (event) { if (event) {
const extracted = extractModData(event) const extracted = extractModData(event)

View File

@ -1,7 +1,11 @@
import { NDKEvent } from '@nostr-dev-kit/ndk' import { Event, Filter, kinds } from 'nostr-tools'
import { Event } from 'nostr-tools'
import { ModDetails, ModFormState } from '../types'
import { getTagValue } from './nostr' 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. * Extracts and normalizes mod data from an event.
@ -12,7 +16,7 @@ import { getTagValue } from './nostr'
* @param event - The event object from which to extract data. * @param event - The event object from which to extract data.
* @returns A `Partial<PageData>` object containing extracted data. * @returns A `Partial<PageData>` object containing extracted data.
*/ */
export const extractModData = (event: Event | NDKEvent): ModDetails => { export const extractModData = (event: Event): ModDetails => {
// Helper function to safely get the first value of a tag or return a default value // Helper function to safely get the first value of a tag or return a default value
const getFirstTagValue = (tagIdentifier: string, defaultValue = '') => { const getFirstTagValue = (tagIdentifier: string, defaultValue = '') => {
const tagValue = getTagValue(event, tagIdentifier) const tagValue = getTagValue(event, tagIdentifier)
@ -31,7 +35,7 @@ export const extractModData = (event: Event | NDKEvent): ModDetails => {
aTag: getFirstTagValue('a'), aTag: getFirstTagValue('a'),
rTag: getFirstTagValue('r'), rTag: getFirstTagValue('r'),
author: event.pubkey, author: event.pubkey,
edited_at: event.created_at!, edited_at: event.created_at,
body: event.content, body: event.content,
published_at: getIntTagValue('published_at'), published_at: getIntTagValue('published_at'),
game: getFirstTagValue('game'), game: getFirstTagValue('game'),
@ -57,9 +61,7 @@ export const extractModData = (event: Event | NDKEvent): ModDetails => {
* @param events - The array of event objects to be processed. * @param events - The array of event objects to be processed.
* @returns An array of `ModDetails` objects constructed from valid events. * @returns An array of `ModDetails` objects constructed from valid events.
*/ */
export const constructModListFromEvents = ( export const constructModListFromEvents = (events: Event[]): ModDetails[] => {
events: Event[] | NDKEvent[]
): ModDetails[] => {
// Filter and extract mod details from events // Filter and extract mod details from events
const modDetailsList: ModDetails[] = events const modDetailsList: ModDetails[] = events
.filter(isModDataComplete) // Filter out incomplete events .filter(isModDataComplete) // Filter out incomplete events
@ -76,7 +78,7 @@ export const constructModListFromEvents = (
* @param event - The event object to be checked. * @param event - The event object to be checked.
* @returns `true` if the event contains all required data; `false` otherwise. * @returns `true` if the event contains all required data; `false` otherwise.
*/ */
export const isModDataComplete = (event: Event | NDKEvent): boolean => { export const isModDataComplete = (event: Event): boolean => {
// Helper function to check if a tag value is present and not empty // Helper function to check if a tag value is present and not empty
const hasTagValue = (tagIdentifier: string): boolean => { const hasTagValue = (tagIdentifier: string): boolean => {
const value = getTagValue(event, tagIdentifier) const value = getTagValue(event, tagIdentifier)
@ -131,3 +133,89 @@ 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<ModDetails[]> => {
// 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 <a> 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 <div> 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 <a> 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
}

View File

@ -11,7 +11,6 @@ import {
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { RelayController } from '../controllers' import { RelayController } from '../controllers'
import { log, LogType } from './utils' 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). * Get the current time in seconds since the Unix epoch (January 1, 1970).
@ -51,7 +50,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. * @returns {string | null} The value(s) associated with the specified tag identifier, or `null` if the tag is not found.
*/ */
export const getTagValue = ( export const getTagValue = (
event: Event | NDKEvent, event: Event,
tagIdentifier: string tagIdentifier: string
): string[] | null => { ): string[] | null => {
// Find the tag in the event's tags array where the first element matches the tagIdentifier. // Find the tag in the event's tags array where the first element matches the tagIdentifier.
@ -220,24 +219,3 @@ export const sendDMUsingRandomKey = async (
// Return true indicating that the DM was successfully sent // Return true indicating that the DM was successfully sent
return true 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
}