feat: extract mods from relays and display in mods page

This commit is contained in:
daniyal 2024-07-29 11:26:26 +05:00
parent 252eab504f
commit 0c266dde6a
10 changed files with 443 additions and 200 deletions

View File

@ -1 +1,4 @@
# This relay will be used to publish/retrieve events along with other relays (user's relays, admin relays)
VITE_APP_RELAY=wss://relay.degmods.com
# A comma separated list of npubs, Relay list will be extracted for these npubs and this relay list will be used to publish event
VITE_ADMIN_NPUBS= <A comma separated list of npubs>

View File

@ -1,10 +1,12 @@
import '../styles/cardMod.css'
type ModCardProps = {
title: string
summary: string
backgroundLink: string
}
export const ModCard = ({ backgroundLink }: ModCardProps) => {
export const ModCard = ({ title, summary, backgroundLink }: ModCardProps) => {
return (
<a className='cardModMainWrapperLink' href='mods-inner.html'>
<div className='cardModMain'>
@ -15,21 +17,8 @@ export const ModCard = ({ backgroundLink }: ModCardProps) => {
}}
></div>
<div className='cMMBody'>
<h3 className='cMMBodyTitle'>
This is a mod title for an awesome game that will make everyone
happy! The happiest!
</h3>
<p className='cMMBodyText'>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer nec
odio. Praesent libero. Sed cursus ante dapibus diam. Sed nisi. Nulla
quis sem at nibh elementum imperdiet. Duis sagittis ipsum. Praesent
mauris. Fusce nec tellus sed augue semper porta. Mauris massa.
Vestibulum lacinia arcu eget nulla. Class aptent taciti sociosqu ad
litora torquent per conubia nostra, per inceptos himenaeos.
Curabitur sodales ligula in libero.
<br />
<br />
</p>
<h3 className='cMMBodyTitle'>{title}</h3>
<p className='cMMBodyText'>{summary}</p>
</div>
<div className='cMMFoot'>
<div className='cMMFootReactions'>

View File

@ -1,6 +1,6 @@
import NDK, { NDKRelayList, NDKUser } from '@nostr-dev-kit/ndk'
import { UserProfile } from '../types/user'
import { hexToNpub } from '../utils'
import { hexToNpub, log, LogType, npubToHex } from '../utils'
/**
* Singleton class to manage metadata operations using NDK.
@ -8,12 +8,42 @@ import { hexToNpub } from '../utils'
export class MetadataController {
private static instance: MetadataController
private profileNdk: NDK
public adminNpubs: string[]
public adminRelays = new Set<string>()
private constructor() {
this.profileNdk = new NDK({
explicitRelayUrls: ['wss://user.kindpag.es', 'wss://purplepag.es']
})
this.profileNdk.connect()
this.adminNpubs = import.meta.env.VITE_ADMIN_NPUBS.split(',')
}
private setAdminRelays = async () => {
const promises = this.adminNpubs.map((npub) => {
const hexKey = npubToHex(npub)
if (!hexKey) return null
return NDKRelayList.forUser(hexKey, this.profileNdk)
.then((ndkRelayList) => {
if (ndkRelayList) {
ndkRelayList.writeRelayUrls.forEach((url) =>
this.adminRelays.add(url)
)
}
})
.catch((err) => {
log(
true,
LogType.Error,
`❌ Error occurred in getting the instance of NDKRelayList for npub: ${npub}`,
err
)
})
})
await Promise.allSettled(promises)
}
/**
@ -21,9 +51,11 @@ export class MetadataController {
*
* @returns The singleton instance of MetadataController.
*/
public static getInstance(): MetadataController {
public static async getInstance(): Promise<MetadataController> {
if (!MetadataController.instance) {
MetadataController.instance = new MetadataController()
MetadataController.instance.setAdminRelays()
}
return MetadataController.instance
}

View File

@ -68,11 +68,11 @@ export class RelayController {
// todo: window.nostr.getRelays() is not implemented yet in nostr-login, implement the logic once its done
const metadataController = await MetadataController.getInstance()
// Retrieve the list of write relays for the event's public key
// Use a timeout to handle cases where retrieving write relays takes too long
const writeRelaysPromise = MetadataController.getInstance().findWriteRelays(
event.pubkey
)
const writeRelaysPromise = metadataController.findWriteRelays(event.pubkey)
log(this.debug, LogType.Info, ` Finding user's write relays`)
@ -85,6 +85,11 @@ export class RelayController {
return [] as string[] // Return an empty array if an error occurs
})
// push admin relay urls obtained from metadata controller to writeRelayUrls list
metadataController.adminRelays.forEach((url) => {
writeRelayUrls.push(url)
})
// Connect to all write relays obtained from MetadataController
const relayPromises = writeRelayUrls.map((relayUrl) =>
this.connectRelay(relayUrl)
@ -141,17 +146,26 @@ export class RelayController {
}
/**
* Asynchronously retrieves an event from a set of relays based on a provided filter.
* Asynchronously retrieves multiple event from a set of relays based on a provided filter.
* If no relays are specified, it defaults to using connected relays.
*
* @param {Filter} 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 (
fetchEvents = async (
filter: Filter,
relays: string[] = []
): Promise<Event | null> => {
): Promise<Event[]> => {
// add app relay to relays array
relays.push(import.meta.env.VITE_APP_RELAY)
const metadataController = await MetadataController.getInstance()
// add admin relays to relays array
metadataController.adminRelays.forEach((url) => {
relays.push(url)
})
// Connect to all specified relays
const relayPromises = relays.map((relayUrl) => this.connectRelay(relayUrl))
await Promise.allSettled(relayPromises)
@ -163,6 +177,7 @@ export class RelayController {
}
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 = this.connectedRelays.map((relay) => {
@ -171,8 +186,17 @@ export class RelayController {
const sub = relay.subscribe([filter], {
// Handle incoming events
onevent: (e) => {
log(this.debug, LogType.Info, ` ${relay.url} : Received Event`, e)
events.push(e)
// Add the event to the array if it's not a duplicate
if (!eventIds.has(e.id)) {
log(
this.debug,
LogType.Info,
` ${relay.url} : Received Event`,
e
)
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: () => {
@ -187,6 +211,24 @@ export class RelayController {
// Wait for all subscriptions to complete
await Promise.allSettled(subPromises)
// Return the most recent event, or null if no events were received
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> => {
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)

View File

@ -28,12 +28,13 @@ export const Header = () => {
} else {
dispatch(setIsAuth(true))
dispatch(setUser({ npub }))
const metadataController = MetadataController.getInstance()
MetadataController.getInstance().then((metadataController) => {
metadataController.findMetadata(npub).then((userProfile) => {
if (userProfile) {
dispatch(setUser(userProfile))
}
})
})
}
}
})

View File

@ -150,9 +150,21 @@ export const HomePage = () => {
<h2 className='IBMSMTitleMainHeading'>Awesome Mods</h2>
</div>
<div className='IBMSMList IBMSMListAlt'>
<ModCard backgroundLink='https://image.nostr.build/65a11a00bb99c11561735f861c51b498cf9dc07d02beff7303fe7f7ab52f3987.jpg' />
<ModCard backgroundLink='https://web.archive.org/web/20240215093752im_/https://staticdelivery.nexusmods.com/mods/6144/images/headers/13_1707966408.jpg' />
<ModCard backgroundLink='https://steamuserimages-a.akamaihd.net/ugc/2013708095892656347/39A93A2B1EB05E725214373849F8E37FCEB4C2EA/?imw=512&amp;imh=256&amp;ima=fit&amp;impolicy=Letterbox&amp;imcolor=%23000000&amp;letterbox=true' />
<ModCard
title='This is a mod title for an awesome game that will make everyone happy! The happiest!'
summary='Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
backgroundLink='https://image.nostr.build/65a11a00bb99c11561735f861c51b498cf9dc07d02beff7303fe7f7ab52f3987.jpg'
/>
<ModCard
title='This is a mod title for an awesome game that will make everyone happy! The happiest!'
summary='Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
backgroundLink='https://web.archive.org/web/20240215093752im_/https://staticdelivery.nexusmods.com/mods/6144/images/headers/13_1707966408.jpg'
/>
<ModCard
title='This is a mod title for an awesome game that will make everyone happy! The happiest!'
summary='Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
backgroundLink='https://steamuserimages-a.akamaihd.net/ugc/2013708095892656347/39A93A2B1EB05E725214373849F8E37FCEB4C2EA/?imw=512&amp;imh=256&amp;ima=fit&amp;impolicy=Letterbox&amp;imcolor=%23000000&amp;letterbox=true'
/>
</div>
<div className='IBMSMAction'>
<a
@ -169,10 +181,26 @@ export const HomePage = () => {
<h2 className='IBMSMTitleMainHeading'>Latest Mods</h2>
</div>
<div className='IBMSMList'>
<ModCard backgroundLink='https://image.nostr.build/65a11a00bb99c11561735f861c51b498cf9dc07d02beff7303fe7f7ab52f3987.jpg' />
<ModCard backgroundLink='https://web.archive.org/web/20240215093752im_/https://staticdelivery.nexusmods.com/mods/6144/images/headers/13_1707966408.jpg' />
<ModCard backgroundLink='https://steamuserimages-a.akamaihd.net/ugc/2013708095892656347/39A93A2B1EB05E725214373849F8E37FCEB4C2EA/?imw=512&amp;imh=256&amp;ima=fit&amp;impolicy=Letterbox&amp;imcolor=%23000000&amp;letterbox=true' />
<ModCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
<ModCard
title='This is a mod title for an awesome game that will make everyone happy! The happiest!'
summary='Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
backgroundLink='https://image.nostr.build/65a11a00bb99c11561735f861c51b498cf9dc07d02beff7303fe7f7ab52f3987.jpg'
/>
<ModCard
title='This is a mod title for an awesome game that will make everyone happy! The happiest!'
summary='Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
backgroundLink='https://web.archive.org/web/20240215093752im_/https://staticdelivery.nexusmods.com/mods/6144/images/headers/13_1707966408.jpg'
/>
<ModCard
title='This is a mod title for an awesome game that will make everyone happy! The happiest!'
summary='Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
backgroundLink='https://steamuserimages-a.akamaihd.net/ugc/2013708095892656347/39A93A2B1EB05E725214373849F8E37FCEB4C2EA/?imw=512&amp;imh=256&amp;ima=fit&amp;impolicy=Letterbox&amp;imcolor=%23000000&amp;letterbox=true'
/>
<ModCard
title='This is a mod title for an awesome game that will make everyone happy! The happiest!'
summary='Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png'
/>
</div>
<div className='IBMSMAction'>

View File

@ -3,12 +3,94 @@ import '../styles/pagination.css'
import '../styles/styles.css'
import '../styles/search.css'
import { ModCard } from '../components/ModCard'
import { useDidMount } from '../hooks'
import { Filter, kinds } from 'nostr-tools'
import { RelayController } from '../controllers'
import { constructModListFromEvents, log, LogType } from '../utils'
import { toast } from 'react-toastify'
import { LoadingSpinner } from '../components/LoadingSpinner'
import { useState } from 'react'
import { ModDetails } from '../types'
enum SortByFilter {
Latest = 'Latest',
Oldest = 'Oldest',
Best_Rated = 'Best Rated',
Worst_Rated = 'Worst Rated'
}
enum NSFWFilter {
Hide_NSFW = 'Hide NSFW',
Show_NSFW = 'Show NSFW',
Only_NSFW = 'Only NSFW'
}
enum ModeratedFilter {
Moderated = 'Moderated',
Unmoderated = 'Unmoderated'
}
export const ModsPage = () => {
const [isFetching, setIsFetching] = useState(true)
const [mods, setMods] = useState<ModDetails[]>([])
useDidMount(async () => {
const filter: Filter = {
kinds: [kinds.ClassifiedListing]
}
RelayController.getInstance()
.fetchEvents(filter, [])
.then((events) => {
const modList = constructModListFromEvents(events)
setMods(modList)
})
.catch((err) => {
log(
true,
LogType.Error,
'An error occurred in fetching mods from relays',
err
)
toast.error('An error occurred in fetching mods from relays')
})
.finally(() => {
setIsFetching(false)
})
})
if (isFetching)
return <LoadingSpinner desc='Fetching mod details from relays' />
return (
<div className='InnerBodyMain'>
<div className='ContainerMain'>
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
<PageTitleRow />
<Filters />
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMList'>
{mods.map((mod, index) => (
<ModCard
key={`mod-${index}`}
title={mod.title}
summary={mod.summary}
backgroundLink={mod.featuredImageUrl}
/>
))}
</div>
</div>
<Pagination />
</div>
</div>
</div>
)
}
const PageTitleRow = () => {
return (
<div className='IBMSecMain'>
<div className='SearchMainWrapper'>
<div className='IBMSMTitleMain'>
@ -34,7 +116,11 @@ export const ModsPage = () => {
</div>
</div>
</div>
)
}
const Filters = () => {
return (
<div className='IBMSecMain'>
<div className='FiltersMain'>
<div className='FiltersMainElement'>
@ -47,19 +133,16 @@ export const ModsPage = () => {
>
Latest
</button>
<div className='dropdown-menu dropdownMainMenu'>
<a className='dropdown-item dropdownMainMenuItem' href='#'>
Latest
</a>
<a className='dropdown-item dropdownMainMenuItem' href='#'>
Oldest
</a>
<a className='dropdown-item dropdownMainMenuItem' href='#'>
Best Rated
</a>
<a className='dropdown-item dropdownMainMenuItem' href='#'>
Worst Rated
</a>
{Object.values(SortByFilter).map((item, index) => (
<div
key={`sortByFilterItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
>
{item}
</div>
))}
</div>
</div>
</div>
@ -74,15 +157,14 @@ export const ModsPage = () => {
Show all (filtered)
</button>
<div className='dropdown-menu dropdownMainMenu'>
<a className='dropdown-item dropdownMainMenuItem' href='#'>
Show all (filtered)
</a>
<a className='dropdown-item dropdownMainMenuItem' href='#'>
Show all (unfiltered)
</a>
<a className='dropdown-item dropdownMainMenuItem' href='#'>
Show from my follow list
</a>
{Object.values(ModeratedFilter).map((item, index) => (
<div
key={`moderatedFilterItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
>
{item}
</div>
))}
</div>
</div>
</div>
@ -97,16 +179,14 @@ export const ModsPage = () => {
Hide NSFW
</button>
<div className='dropdown-menu dropdownMainMenu'>
<a className='dropdown-item dropdownMainMenuItem' href='#'>
Hide NSFW
</a>
<a className='dropdown-item dropdownMainMenuItem' href='#'>
Show NSFW
<br />
</a>
<a className='dropdown-item dropdownMainMenuItem' href='#'>
Only show NSFW
</a>
{Object.values(NSFWFilter).map((item, index) => (
<div
key={`nsfwFilterItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
>
{item}
</div>
))}
</div>
</div>
</div>
@ -121,31 +201,20 @@ export const ModsPage = () => {
Show From: DEG Mods
</button>
<div className='dropdown-menu dropdownMainMenu'>
<a className='dropdown-item dropdownMainMenuItem' href='#'>
Show From: DEG Mods
</a>
<a className='dropdown-item dropdownMainMenuItem' href='#'>
Show All
</a>
<div className='dropdown-item dropdownMainMenuItem'>
Show From: {window.location.host}
</div>
<div className='dropdown-item dropdownMainMenuItem'>Show All</div>
</div>
</div>
</div>
</div>
</div>
)
}
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMList'>
<ModCard backgroundLink='https://image.nostr.build/65a11a00bb99c11561735f861c51b498cf9dc07d02beff7303fe7f7ab52f3987.jpg' />
<ModCard backgroundLink='https://web.archive.org/web/20240215093752im_/https://staticdelivery.nexusmods.com/mods/6144/images/headers/13_1707966408.jpg' />
<ModCard backgroundLink='https://steamuserimages-a.akamaihd.net/ugc/2013708095892656347/39A93A2B1EB05E725214373849F8E37FCEB4C2EA/?imw=512&amp;imh=256&amp;ima=fit&amp;impolicy=Letterbox&amp;imcolor=%23000000&amp;letterbox=true' />
<ModCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
<ModCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
<ModCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
<ModCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
<ModCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
</div>
</div>
const Pagination = () => {
return (
<div className='IBMSecMain'>
<div className='PaginationMain'>
<div className='PaginationMainInside'>
@ -179,8 +248,5 @@ export const ModsPage = () => {
</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -42,3 +42,52 @@ export const extractModData = (event: Event): ModDetails => {
)
}
}
/**
* Constructs a list of `ModDetails` objects from an array of events.
*
* This function filters out events that do not contain all required data,
* extracts the necessary information from the valid events, and constructs
* `ModDetails` objects.
*
* @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[] => {
// Filter and extract mod details from events
const modDetailsList: ModDetails[] = events
.filter(isModDataComplete) // Filter out incomplete events
.map((event) => extractModData(event)) // Extract data and construct ModDetails
return modDetailsList
}
/**
* Checks if the provided event contains all the required data for constructing a `ModDetails` object.
*
* This function verifies that the event has the necessary tags and values to construct a `ModDetails` object.
*
* @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 => {
// Helper function to check if a tag value is present and not empty
const hasTagValue = (tagIdentifier: string): boolean => {
const value = getTagValue(event, tagIdentifier)
return !!value && value.length > 0 && value[0].trim() !== ''
}
// Check if all required fields are present
return (
hasTagValue('t') &&
hasTagValue('published_at') &&
hasTagValue('game') &&
hasTagValue('title') &&
hasTagValue('featuredImageUrl') &&
hasTagValue('summary') &&
hasTagValue('nsfw') &&
getTagValue(event, 'screenshotsUrls') !== null &&
getTagValue(event, 'tags') !== null &&
getTagValue(event, 'downloadUrls') !== null
)
}

View File

@ -52,3 +52,35 @@ export const getTagValue = (
// Return null if no matching tag is found.
return null
}
/**
* @param hexKey hex private or public key
* @returns whether or not is key valid
*/
const validateHex = (hexKey: string) => {
return hexKey.match(/^[a-f0-9]{64}$/)
}
/**
* NPUB provided - it will convert NPUB to HEX
* HEX provided - it will return HEX
*
* @param pubKey in NPUB, HEX format
* @returns HEX format
*/
export const npubToHex = (pubKey: string): string | null => {
// If key is NPUB
if (pubKey.startsWith('npub1')) {
try {
return nip19.decode(pubKey).data as string
} catch (error) {
return null
}
}
// valid hex key
if (validateHex(pubKey)) return pubKey
// Not a valid hex key
return null
}

1
src/vite-env.d.ts vendored
View File

@ -2,6 +2,7 @@
interface ImportMetaEnv {
readonly VITE_APP_RELAY: string
readonly VITE_ADMIN_NPUBS: string
// more env variables...
}