From 0c266dde6ae22a149793cfc3f503ea2fdcea95a0 Mon Sep 17 00:00:00 2001 From: daniyal Date: Mon, 29 Jul 2024 11:26:26 +0500 Subject: [PATCH] feat: extract mods from relays and display in mods page --- .env.example | 5 +- src/components/ModCard.tsx | 21 +- src/controllers/metadata.ts | 36 +++- src/controllers/relay.ts | 58 +++++- src/layout/header.tsx | 11 +- src/pages/home.tsx | 42 +++- src/pages/mods.tsx | 388 +++++++++++++++++++++--------------- src/utils/mod.ts | 49 +++++ src/utils/nostr.ts | 32 +++ src/vite-env.d.ts | 1 + 10 files changed, 443 insertions(+), 200 deletions(-) diff --git a/.env.example b/.env.example index e9f46fa..f726dee 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,4 @@ -VITE_APP_RELAY=wss://relay.degmods.com \ No newline at end of file +# 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= \ No newline at end of file diff --git a/src/components/ModCard.tsx b/src/components/ModCard.tsx index eb2f17c..055b1c9 100644 --- a/src/components/ModCard.tsx +++ b/src/components/ModCard.tsx @@ -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 (
@@ -15,21 +17,8 @@ export const ModCard = ({ backgroundLink }: ModCardProps) => { }} >
-

- This is a mod title for an awesome game that will make everyone - happy! The happiest! -

-

- 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. -
-
-

+

{title}

+

{summary}

diff --git a/src/controllers/metadata.ts b/src/controllers/metadata.ts index c1cac3a..7ef3be4 100644 --- a/src/controllers/metadata.ts +++ b/src/controllers/metadata.ts @@ -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() 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 { if (!MetadataController.instance) { MetadataController.instance = new MetadataController() + + MetadataController.instance.setAdminRelays() } return MetadataController.instance } diff --git a/src/controllers/relay.ts b/src/controllers/relay.ts index 85ff8f9..1065960 100644 --- a/src/controllers/relay.ts +++ b/src/controllers/relay.ts @@ -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} - Returns a promise that resolves to the found event or null if not found. */ - fetchEvent = async ( + fetchEvents = async ( filter: Filter, relays: string[] = [] - ): Promise => { + ): Promise => { + // 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() // 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} - Returns a promise that resolves to the found event or null if not found. + */ + fetchEvent = async ( + filter: Filter, + relays: string[] = [] + ): Promise => { + 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) diff --git a/src/layout/header.tsx b/src/layout/header.tsx index a7fd1f5..da0cec4 100644 --- a/src/layout/header.tsx +++ b/src/layout/header.tsx @@ -28,11 +28,12 @@ export const Header = () => { } else { dispatch(setIsAuth(true)) dispatch(setUser({ npub })) - const metadataController = MetadataController.getInstance() - metadataController.findMetadata(npub).then((userProfile) => { - if (userProfile) { - dispatch(setUser(userProfile)) - } + MetadataController.getInstance().then((metadataController) => { + metadataController.findMetadata(npub).then((userProfile) => { + if (userProfile) { + dispatch(setUser(userProfile)) + } + }) }) } } diff --git a/src/pages/home.tsx b/src/pages/home.tsx index 84c9ad2..71dbfd1 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -150,9 +150,21 @@ export const HomePage = () => {

Awesome Mods

- - - + + +
{

Latest Mods

- - - - + + + +
diff --git a/src/pages/mods.tsx b/src/pages/mods.tsx index e4205ff..a253136 100644 --- a/src/pages/mods.tsx +++ b/src/pages/mods.tsx @@ -3,180 +3,114 @@ 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([]) + + 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 + return (
-
-
-
-

Mods

-
-
-
-
- - -
-
-
-
-
- -
+ +
- - - - - - - - + {mods.map((mod, index) => ( + + ))}
- + ) +} + +const PageTitleRow = () => { + return ( +
+ @@ -184,3 +118,135 @@ export const ModsPage = () => {
) } + +const Filters = () => { + return ( +
+
+
+
+ + +
+ {Object.values(SortByFilter).map((item, index) => ( +
+ {item} +
+ ))} +
+
+
+
+
+ +
+ {Object.values(ModeratedFilter).map((item, index) => ( +
+ {item} +
+ ))} +
+
+
+
+
+ +
+ {Object.values(NSFWFilter).map((item, index) => ( +
+ {item} +
+ ))} +
+
+
+
+
+ +
+
+ Show From: {window.location.host} +
+
Show All
+
+
+
+
+
+ ) +} + +const Pagination = () => { + return ( + + ) +} diff --git a/src/utils/mod.ts b/src/utils/mod.ts index 7b70260..6cb04ee 100644 --- a/src/utils/mod.ts +++ b/src/utils/mod.ts @@ -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 + ) +} diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index db2f5bc..0aa99d1 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -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 +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 3e09383..a399374 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -2,6 +2,7 @@ interface ImportMetaEnv { readonly VITE_APP_RELAY: string + readonly VITE_ADMIN_NPUBS: string // more env variables... }