diff --git a/.env.example b/.env.example index f726dee..6404610 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,8 @@ # 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 +VITE_ADMIN_NPUBS= + +# A dedicated npub used for reporting mods, blogs, profile and etc. +VITE_REPORTING_NPUB= \ No newline at end of file diff --git a/.gitea/workflows/release-production.yaml b/.gitea/workflows/release-production.yaml index c6abd6c..1415a50 100644 --- a/.gitea/workflows/release-production.yaml +++ b/.gitea/workflows/release-production.yaml @@ -24,6 +24,7 @@ jobs: run: | echo "VITE_APP_RELAY=${{ vars.VITE_APP_RELAY }}" >> .env echo "VITE_ADMIN_NPUBS=${{ vars.VITE_ADMIN_NPUBS }}" >> .env + echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env cat .env - name: Create Build @@ -34,4 +35,4 @@ jobs: npm -g install cloudron-surfer surfer config --token ${{ secrets.PRODUCTION_CLOUDRON_SURFER_TOKEN }} --server degmods.com surfer put dist/* / --all -d - surfer put dist/.well-known / --all \ No newline at end of file + surfer put dist/.well-known / --all diff --git a/.gitea/workflows/release-staging.yaml b/.gitea/workflows/release-staging.yaml index 9fe8173..c08b4e8 100644 --- a/.gitea/workflows/release-staging.yaml +++ b/.gitea/workflows/release-staging.yaml @@ -24,6 +24,7 @@ jobs: run: | echo "VITE_APP_RELAY=${{ vars.VITE_APP_RELAY }}" >> .env echo "VITE_ADMIN_NPUBS=${{ vars.VITE_ADMIN_NPUBS }}" >> .env + echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env cat .env - name: Create Build diff --git a/package-lock.json b/package-lock.json index b83212c..0502f4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3808,9 +3808,9 @@ } }, "node_modules/micromatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", - "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "dependencies": { "braces": "^3.0.3", diff --git a/src/components/Inputs.tsx b/src/components/Inputs.tsx index a94be5f..5a30009 100644 --- a/src/components/Inputs.tsx +++ b/src/components/Inputs.tsx @@ -1,7 +1,7 @@ import Link from '@tiptap/extension-link' -import { EditorProvider, useCurrentEditor } from '@tiptap/react' +import { Editor, EditorContent, useEditor } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' -import React from 'react' +import React, { useEffect } from 'react' import '../styles/styles.css' import '../styles/tiptap.scss' @@ -112,34 +112,56 @@ type RichTextEditorProps = { } const RichTextEditor = ({ content, updateContent }: RichTextEditorProps) => { + const editor = useEditor({ + extensions: [StarterKit, Link], + onUpdate: ({ editor }) => { + // Update the state when the editor content changes + updateContent(editor.getHTML()) + }, + content + }) + + // Update editor content when the `content` prop changes + useEffect(() => { + if (editor && editor.getHTML() !== content) { + editor.commands.setContent(content, false) + } + }, [content, editor]) + return ( - } - extensions={[StarterKit, Link]} - content={content} - onUpdate={({ editor }) => { - // Update the state when the editor content changes - updateContent(editor.getHTML()) - }} - > + {editor && ( + <> + + + > + )} ) } -const MenuBar = () => { - const { editor } = useCurrentEditor() - - if (!editor) { - return null - } +type MenuBarProps = { + editor: Editor +} +const MenuBar = ({ editor }: MenuBarProps) => { const setLink = () => { - const url = prompt('URL') + // Prompt the user to enter a URL + let url = prompt('URL') + + // Check if the user provided a URL if (url) { + // If the URL doesn't start with 'http://' or 'https://', + // prepend 'https://' to the URL + if (!/^(http|https):\/\//i.test(url)) { + url = `https://${url}` + } + return editor.chain().focus().setLink({ href: url }).run() } + // If no URL was provided (e.g., the user cancels the prompt), + // return false, indicating that the link was not set. return false } diff --git a/src/components/ModCard.tsx b/src/components/ModCard.tsx index 68cc1fb..a47c927 100644 --- a/src/components/ModCard.tsx +++ b/src/components/ModCard.tsx @@ -4,6 +4,7 @@ type ModCardProps = { title: string summary: string backgroundLink: string + link: string handleClick: () => void } @@ -11,10 +12,18 @@ export const ModCard = ({ title, summary, backgroundLink, + link, handleClick }: ModCardProps) => { return ( - + { + e.preventDefault() + handleClick() + }} + > () public adminNpubs: string[] public adminRelays = new Set() + public reportingNpub: string private constructor() { this.ndk = new NDK({ @@ -40,6 +41,7 @@ export class MetadataController { }) this.adminNpubs = import.meta.env.VITE_ADMIN_NPUBS.split(',') + this.reportingNpub = import.meta.env.VITE_REPORTING_NPUB } private setAdminRelays = async () => { @@ -129,38 +131,110 @@ export class MetadataController { return ndkRelayList[userRelaysType] } - public getAdminsMuteLists = async (): Promise => { - // Create a Set to collect all unique muted authors + public getMuteLists = async ( + pubkey?: string + ): Promise<{ + admin: MuteLists + user: MuteLists + }> => { + const adminMutedAuthors = new Set() + const adminMutedPosts = new Set() - const mutedAuthors = new Set() - - // Create an array of promises to fetch mute lists for each npub - const promises = this.adminNpubs.map(async (npub) => { - const hexKey = npubToHex(npub) - if (!hexKey) return + const adminHexKey = npubToHex(this.reportingNpub) + if (adminHexKey) { const muteListEvent = await this.ndk.fetchEvent({ kinds: [kinds.Mutelist], - authors: [hexKey] + authors: [adminHexKey] }) if (muteListEvent) { const list = NDKList.from(muteListEvent) list.items.forEach((item) => { - // Add muted authors to the Set directly if (item[0] === 'p') { - mutedAuthors.add(item[1]) + adminMutedAuthors.add(item[1]) + } else if (item[0] === 'a') { + adminMutedPosts.add(item[1]) } }) } - }) + } - await Promise.allSettled(promises) + const userMutedAuthors = new Set() + const userMutedPosts = new Set() + + if (pubkey) { + const userHexKey = npubToHex(pubkey) + + if (userHexKey) { + const muteListEvent = await this.ndk.fetchEvent({ + kinds: [kinds.Mutelist], + authors: [userHexKey] + }) + + if (muteListEvent) { + const list = NDKList.from(muteListEvent) + + list.items.forEach((item) => { + if (item[0] === 'p') { + userMutedAuthors.add(item[1]) + } else if (item[0] === 'a') { + userMutedPosts.add(item[1]) + } + }) + } + } + } return { - authors: Array.from(mutedAuthors), - eventIds: [] + admin: { + authors: Array.from(adminMutedAuthors), + replaceableEvents: Array.from(adminMutedPosts) + }, + user: { + authors: Array.from(userMutedAuthors), + replaceableEvents: Array.from(userMutedPosts) + } } } + + /** + * Retrieves a list of NSFW (Not Safe For Work) posts that were not specified as NSFW by post author but marked as NSFW by admin. + * + * @returns {Promise} - A promise that resolves to an array of NSFW post identifiers (e.g., URLs or IDs). + */ + public getNSFWList = async (): Promise => { + // Initialize an array to store the NSFW post identifiers + const nsfwPosts: string[] = [] + + // Convert the public key (npub) to a hexadecimal format + const hexKey = npubToHex(this.reportingNpub) + + // If the conversion is successful and we have a hexKey + if (hexKey) { + // Fetch the event that contains the NSFW list + const nsfwListEvent = await this.ndk.fetchEvent({ + kinds: [kinds.Curationsets], + authors: [hexKey], + '#d': ['nsfw'] + }) + + if (nsfwListEvent) { + // Convert the event data to an NDKList, which is a structured list format + const list = NDKList.from(nsfwListEvent) + + // Iterate through the items in the list + list.items.forEach((item) => { + if (item[0] === 'a') { + // Add the identifier of the NSFW post to the nsfwPosts array + nsfwPosts.push(item[1]) + } + }) + } + } + + // Return the array of NSFW post identifiers + return nsfwPosts + } } diff --git a/src/controllers/relay.ts b/src/controllers/relay.ts index 98ab1f1..f688878 100644 --- a/src/controllers/relay.ts +++ b/src/controllers/relay.ts @@ -1,4 +1,5 @@ import { Event, Filter, kinds, Relay } from 'nostr-tools' +import { ModDetails } from '../types' import { extractZapAmount, log, @@ -7,7 +8,6 @@ import { timeout } from '../utils' import { MetadataController, UserRelaysType } from './metadata' -import { ModDetails } from '../types' /** * Singleton class to manage relay operations. @@ -155,6 +155,103 @@ export class RelayController { return publishedOnRelays } + /** + * Publishes an encrypted DM to receiver's read relays. + * + * This method connects to the application relay and a set of receiver's read relays + * obtained from the `MetadataController`. It then publishes the event to + * all connected relays and returns a list of relays where the event was successfully published. + * + * @param event - The event to be published. + * @returns A promise that resolves to an array of URLs of relays where the event was published, + * or an empty array if no relays were connected or the event could not be published. + */ + publishDM = async (event: Event, receiver: string): Promise => { + // Connect to the application relay specified by environment variable + const appRelayPromise = this.connectRelay(import.meta.env.VITE_APP_RELAY) + + // todo: window.nostr.getRelays() is not implemented yet in nostr-login, implement the logic once its done + + const metadataController = await MetadataController.getInstance() + + // Retrieve the list of read relays for the receiver + // Use a timeout to handle cases where retrieving read relays takes too long + const readRelaysPromise = metadataController.findUserRelays( + receiver, + UserRelaysType.Read + ) + + log(this.debug, LogType.Info, `ℹ Finding receiver's read relays`) + + // Use Promise.race to either get the read relay URLs or timeout + const readRelayUrls = await Promise.race([ + readRelaysPromise, + timeout() // This is a custom timeout function that rejects the promise after a specified time + ]).catch((err) => { + log(this.debug, LogType.Error, err) + return [] as string[] // Return an empty array if an error occurs + }) + + // push admin relay urls obtained from metadata controller to readRelayUrls list + metadataController.adminRelays.forEach((url) => { + readRelayUrls.push(url) + }) + + // Connect to all write relays obtained from MetadataController + const relayPromises = readRelayUrls.map((relayUrl) => + this.connectRelay(relayUrl) + ) + + // Wait for all relay connections to settle (either fulfilled or rejected) + await Promise.allSettled([appRelayPromise, ...relayPromises]) + + // Check if any relays are connected; if not, log an error and return null + if (this.connectedRelays.length === 0) { + log(this.debug, LogType.Error, 'No relay is connected!') + return [] + } + + const publishedOnRelays: string[] = [] // List to track which relays successfully published the event + + // Create a promise for publishing the event to each connected relay + const publishPromises = this.connectedRelays.map((relay) => { + log( + this.debug, + LogType.Info, + `⬆️ nostr (${relay.url}): Sending event:`, + event + ) + + return Promise.race([ + relay.publish(event), // Publish the event to the relay + timeout(30000) // Set a timeout to handle cases where publishing takes too long + ]) + .then((res) => { + log( + this.debug, + LogType.Info, + `⬆️ nostr (${relay.url}): Publish result:`, + res + ) + publishedOnRelays.push(relay.url) // Add the relay URL to the list of successfully published relays + }) + .catch((err) => { + log( + this.debug, + LogType.Error, + `❌ nostr (${relay.url}): Publish error!`, + err + ) + }) + }) + + // Wait for all publish operations to complete (either fulfilled or rejected) + await Promise.allSettled(publishPromises) + + // Return the list of relay URLs where the event was published + return publishedOnRelays + } + /** * 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. @@ -247,6 +344,46 @@ export class RelayController { return events[0] || null } + /** + * 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 + ) => { + // 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. This is an asynchronous operation. + const usersRelaysPromise = metadataController.findUserRelays( + hexKey, + userRelaysType + ) + + log(true, LogType.Info, `ℹ Finding user's relays`) + + // Use Promise.race to attempt to resolve the user's relays, or timeout if it takes too long + const relayUrls = await Promise.race([ + usersRelaysPromise, + timeout() // This is a custom timeout function that rejects the promise after a specified time + ]).catch((err) => { + // Log an error if the relay fetching operation fails + log(true, LogType.Error, err) + // Return an empty array to indicate failure in retrieving relay URLs + return [] as string[] + }) + + // Fetch the event from the user's relays using the provided filter and relay URLs + return this.fetchEvent(filter, relayUrls) + } + getTotalZapAmount = async ( modDetails: ModDetails, currentLoggedInUser?: string diff --git a/src/pages/home.tsx b/src/pages/home.tsx index 87d2d2a..9a9cef3 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -154,6 +154,7 @@ export const HomePage = () => { title='Placeholder' summary='Lorem ipsum dolor sit amet, consectetur adipiscing elit.' backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' + link='' handleClick={() => { alert( 'these are dummy mods. So navigation on these are not implemented yet' @@ -164,6 +165,7 @@ export const HomePage = () => { title='Placeholder' summary='Lorem ipsum dolor sit amet, consectetur adipiscing elit.' backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' + link='' handleClick={() => { alert( 'these are dummy mods. So navigation on these are not implemented yet' @@ -174,6 +176,7 @@ export const HomePage = () => { title='Placeholder' summary='Lorem ipsum dolor sit amet, consectetur adipiscing elit.' backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' + link='' handleClick={() => { alert( 'these are dummy mods. So navigation on these are not implemented yet' @@ -200,6 +203,7 @@ export const HomePage = () => { title='Placeholder' summary='Lorem ipsum dolor sit amet, consectetur adipiscing elit.' backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' + link='' handleClick={() => { alert( 'these are dummy mods. So navigation on these are not implemented yet' @@ -210,6 +214,7 @@ export const HomePage = () => { title='Placeholder' summary='Lorem ipsum dolor sit amet, consectetur adipiscing elit.' backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' + link='' handleClick={() => { alert( 'these are dummy mods. So navigation on these are not implemented yet' @@ -220,6 +225,7 @@ export const HomePage = () => { title='Placeholder' summary='Lorem ipsum dolor sit amet, consectetur adipiscing elit.' backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' + link='' handleClick={() => { alert( 'these are dummy mods. So navigation on these are not implemented yet' @@ -230,6 +236,7 @@ export const HomePage = () => { title='Placeholder' summary='Lorem ipsum dolor sit amet, consectetur adipiscing elit.' backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' + link='' handleClick={() => { alert( 'these are dummy mods. So navigation on these are not implemented yet' diff --git a/src/pages/innerMod.tsx b/src/pages/innerMod.tsx index 2e8c4de..b3b401d 100644 --- a/src/pages/innerMod.tsx +++ b/src/pages/innerMod.tsx @@ -2,8 +2,15 @@ import Link from '@tiptap/extension-link' import { EditorContent, useEditor } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import { formatDate } from 'date-fns' -import { Filter, nip19 } from 'nostr-tools' -import { Dispatch, SetStateAction, useCallback, useRef, useState } from 'react' +import { Filter, kinds, nip19, UnsignedEvent } from 'nostr-tools' +import { + Dispatch, + SetStateAction, + useCallback, + useEffect, + useRef, + useState +} from 'react' import { useNavigate, useParams } from 'react-router-dom' import { toast } from 'react-toastify' import { BlogCard } from '../components/BlogCard' @@ -13,6 +20,7 @@ import { ZapButtons, ZapPresets, ZapQR } from '../components/Zap' import { MetadataController, RelayController, + UserRelaysType, ZapController } from '../controllers' import { useAppSelector, useDidMount } from '../hooks' @@ -37,7 +45,11 @@ import { getFilenameFromUrl, log, LogType, - unformatNumber + now, + npubToHex, + sendDMUsingRandomKey, + unformatNumber, + signAndPublish } from '../utils' export const InnerModPage = () => { @@ -114,6 +126,7 @@ export const InnerModPage = () => { naddr={naddr!} game={modData.game} author={modData.author} + aTag={modData.aTag} /> { +const Game = ({ naddr, game, author, aTag }: GameProps) => { const navigate = useNavigate() const userState = useAppSelector((state) => state.user) + const [isLoading, setIsLoading] = useState(false) + const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') + const [showReportPopUp, setShowReportPopUp] = useState(false) + const [isBlocked, setIsBlocked] = useState(false) + const [isAddedToNSFW, setIsAddedToNSFW] = useState(false) + + useEffect(() => { + if (userState.auth && userState.user?.pubkey) { + const pubkey = userState.user.pubkey as string + + const muteListFilter: Filter = { + kinds: [kinds.Mutelist], + authors: [pubkey] + } + + RelayController.getInstance() + .fetchEventFromUserRelays(muteListFilter, pubkey, UserRelaysType.Write) + .then((event) => { + if (event) { + // get a list of tags + const tags = event.tags + const blocked = + tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== + -1 + + setIsBlocked(blocked) + } + }) + + if ( + userState.user.npub && + userState.user.npub === import.meta.env.VITE_REPORTING_NPUB + ) { + const nsfwListFilter: Filter = { + kinds: [kinds.Curationsets], + authors: [pubkey], + '#d': ['nsfw'] + } + + RelayController.getInstance() + .fetchEventFromUserRelays( + nsfwListFilter, + pubkey, + UserRelaysType.Write + ) + .then((event) => { + if (event) { + // get a list of tags + const tags = event.tags + const existsInNSFWList = + tags.findIndex( + (item) => item[0] === 'a' && item[1] === aTag + ) !== -1 + + setIsAddedToNSFW(existsInNSFWList) + } + }) + } + } + }, [userState, aTag]) + + const handleBlock = async () => { + let hexPubkey: string + + setIsLoading(true) + setLoadingSpinnerDesc('Getting user pubkey') + + if (userState.auth && userState.user?.pubkey) { + hexPubkey = userState.user.pubkey as string + } else { + hexPubkey = (await window.nostr?.getPublicKey()) as string + } + + if (!hexPubkey) { + toast.error('Could not get pubkey for updating mute list') + setIsLoading(false) + return + } + + setLoadingSpinnerDesc(`Finding user's mute list`) + + // Define the event filter to search for the user's mute list events. + // We look for events of a specific kind (Mutelist) authored by the given hexPubkey. + const filter: Filter = { + kinds: [kinds.Mutelist], + authors: [hexPubkey] + } + + // Fetch the mute list event from the relays. This returns the event containing the user's mute list. + const muteListEvent = + await RelayController.getInstance().fetchEventFromUserRelays( + filter, + hexPubkey, + UserRelaysType.Write + ) + + let unsignedEvent: UnsignedEvent + + if (muteListEvent) { + // get a list of tags + const tags = muteListEvent.tags + const alreadyExists = + tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== -1 + + if (alreadyExists) { + setIsLoading(false) + setIsBlocked(true) + return toast.warn(`Mod reference is already in user's mute list`) + } + + tags.push(['a', aTag]) + + unsignedEvent = { + pubkey: muteListEvent.pubkey, + kind: muteListEvent.kind, + content: muteListEvent.content, + created_at: now(), + tags: [...tags] + } + } else { + unsignedEvent = { + pubkey: hexPubkey, + kind: kinds.Mutelist, + content: '', + created_at: now(), + tags: [['a', aTag]] + } + } + + setLoadingSpinnerDesc('Updating mute list event') + + const isUpdated = await signAndPublish(unsignedEvent) + if (isUpdated) { + setIsBlocked(true) + } + setIsLoading(false) + } + + const handleUnblock = async () => { + const pubkey = userState.user?.pubkey as string + + const filter: Filter = { + kinds: [kinds.Mutelist], + authors: [pubkey] + } + + setIsLoading(true) + setLoadingSpinnerDesc(`Finding user's mute list`) + + // Fetch the mute list event from the relays. This returns the event containing the user's mute list. + const muteListEvent = + await RelayController.getInstance().fetchEventFromUserRelays( + filter, + pubkey, + UserRelaysType.Write + ) + + if (!muteListEvent) { + toast.error(`Couldn't get user's mute list event from relays`) + return + } + + const tags = muteListEvent.tags + + const unsignedEvent: UnsignedEvent = { + pubkey: muteListEvent.pubkey, + kind: muteListEvent.kind, + content: muteListEvent.content, + created_at: now(), + tags: tags.filter((item) => item[0] !== 'a' || item[1] !== aTag) + } + + setLoadingSpinnerDesc('Updating mute list event') + const isUpdated = await signAndPublish(unsignedEvent) + if (isUpdated) { + setIsBlocked(false) + } + + setIsLoading(false) + } + + const handleBlockNSFW = async () => { + const pubkey = userState.user?.pubkey as string | undefined + + if (!pubkey) return + + const filter: Filter = { + kinds: [kinds.Curationsets], + authors: [pubkey], + '#d': ['nsfw'] + } + + setIsLoading(true) + setLoadingSpinnerDesc('Finding NSFW list') + + const nsfwListEvent = + await RelayController.getInstance().fetchEventFromUserRelays( + filter, + pubkey, + UserRelaysType.Write + ) + + let unsignedEvent: UnsignedEvent + + if (nsfwListEvent) { + // get a list of tags + const tags = nsfwListEvent.tags + const alreadyExists = + tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== -1 + + if (alreadyExists) { + setIsLoading(false) + setIsAddedToNSFW(true) + return toast.warn(`Mod reference is already in user's nsfw list`) + } + + tags.push(['a', aTag]) + + unsignedEvent = { + pubkey: nsfwListEvent.pubkey, + kind: nsfwListEvent.kind, + content: nsfwListEvent.content, + created_at: now(), + tags: [...tags] + } + } else { + unsignedEvent = { + pubkey: pubkey, + kind: kinds.Curationsets, + content: '', + created_at: now(), + tags: [ + ['a', aTag], + ['d', 'nsfw'] + ] + } + } + + setLoadingSpinnerDesc('Updating nsfw list event') + + const isUpdated = await signAndPublish(unsignedEvent) + if (isUpdated) { + setIsAddedToNSFW(true) + } + setIsLoading(false) + } + + const handleUnblockNSFW = async () => { + const pubkey = userState.user?.pubkey as string + + const filter: Filter = { + kinds: [kinds.Curationsets], + authors: [pubkey], + '#d': ['nsfw'] + } + + setIsLoading(true) + setLoadingSpinnerDesc('Finding NSFW list') + + const nsfwListEvent = + await RelayController.getInstance().fetchEventFromUserRelays( + filter, + pubkey, + UserRelaysType.Write + ) + + if (!nsfwListEvent) { + toast.error(`Couldn't get nsfw list event from relays`) + return + } + + const tags = nsfwListEvent.tags + + const unsignedEvent: UnsignedEvent = { + pubkey: nsfwListEvent.pubkey, + kind: nsfwListEvent.kind, + content: nsfwListEvent.content, + created_at: now(), + tags: tags.filter((item) => item[0] !== 'a' || item[1] !== aTag) + } + + setLoadingSpinnerDesc('Updating nsfw list event') + const isUpdated = await signAndPublish(unsignedEvent) + if (isUpdated) { + setIsAddedToNSFW(false) + } + + setIsLoading(false) + } + + const isAdmin = + userState.user?.npub && + userState.user.npub === import.meta.env.VITE_REPORTING_NPUB return ( - - - Mod for: - - {game} - - - - - + {isLoading && } + + + Mod for: + + {game} + + + + - - - - - {userState.auth && userState.user?.pubkey === author && ( + + + + + + {userState.auth && userState.user?.pubkey === author && ( + navigate(getModsEditPageRoute(naddr))} + > + + + + Edit + + )} + navigate(getModsEditPageRoute(naddr))} + onClick={() => { + copyTextToClipboard(window.location.href).then((isCopied) => { + if (isCopied) toast.success('Url copied to clipboard!') + }) + }} > { fill='currentColor' className='IBMSMSMSSS_Author_Top_Icon' > - + - Edit + Copy URL - )} - - { - copyTextToClipboard(window.location.href).then((isCopied) => { - if (isCopied) toast.success('Url copied to clipboard!') - }) - }} - > - + + + + Share + + setShowReportPopUp(true)} > - - - Copy URL - - - + + + Report + + - - - Share - - - - - - Report - - - - - - Block Post - + + + + {isBlocked ? 'Unblock' : 'Block'} Post + + {isAdmin && ( + + + + + {isAddedToNSFW ? 'Un-mark' : 'Mark'} as NSFW + + )} + - + {showReportPopUp && ( + setShowReportPopUp(false)} + /> + )} + > + ) +} + +type ReportPopupProps = { + aTag: string + handleClose: () => void +} + +const ReportPopup = ({ aTag, handleClose }: ReportPopupProps) => { + const userState = useAppSelector((state) => state.user) + const [selectedOptions, setSelectedOptions] = useState({ + actuallyCP: false, + spam: false, + scam: false, + notAGameMod: false, + stolenGameMod: false, + wasntTaggedNSFW: false, + otherReason: false + }) + const [isLoading, setIsLoading] = useState(false) + const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') + + const handleCheckboxChange = (option: keyof typeof selectedOptions) => { + setSelectedOptions((prevState) => ({ + ...prevState, + [option]: !prevState[option] + })) + } + + const handleSubmit = async () => { + const selectedOptionsCount = Object.values(selectedOptions).filter( + (isSelected) => isSelected + ).length + + if (selectedOptionsCount === 0) { + toast.error('At least one option should be checked!') + return + } + let hexPubkey: string + + setIsLoading(true) + setLoadingSpinnerDesc('Getting user pubkey') + + if (userState.auth && userState.user?.pubkey) { + hexPubkey = userState.user.pubkey as string + } else { + hexPubkey = (await window.nostr?.getPublicKey()) as string + } + + if (!hexPubkey) { + toast.error('Could not get pubkey for reporting mod!') + setIsLoading(false) + return + } + + const metadataController = await MetadataController.getInstance() + const reportingPubkey = npubToHex(metadataController.reportingNpub) + + if (reportingPubkey === hexPubkey) { + setLoadingSpinnerDesc(`Finding user's mute list`) + // Define the event filter to search for the user's mute list events. + // We look for events of a specific kind (Mutelist) authored by the given hexPubkey. + const filter: Filter = { + kinds: [kinds.Mutelist], + authors: [hexPubkey] + } + + // Fetch the mute list event from the relays. This returns the event containing the user's mute list. + const muteListEvent = + await RelayController.getInstance().fetchEventFromUserRelays( + filter, + hexPubkey, + UserRelaysType.Write + ) + + let unsignedEvent: UnsignedEvent + + if (muteListEvent) { + // get a list of tags + const tags = muteListEvent.tags + const alreadyExists = + tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== -1 + + if (alreadyExists) { + setIsLoading(false) + return toast.warn(`Mod reference is already in user's mute list`) + } + + tags.push(['a', aTag]) + + unsignedEvent = { + pubkey: muteListEvent.pubkey, + kind: muteListEvent.kind, + content: muteListEvent.content, + created_at: now(), + tags: [...tags] + } + } else { + unsignedEvent = { + pubkey: hexPubkey, + kind: kinds.Mutelist, + content: '', + created_at: now(), + tags: [['a', aTag]] + } + } + + setLoadingSpinnerDesc('Updating mute list event') + const isUpdated = await signAndPublish(unsignedEvent) + if (isUpdated) handleClose() + } else { + const href = window.location.href + let message = `I'd like to report ${href} due to following reasons:\n` + + Object.entries(selectedOptions).forEach(([key, value]) => { + if (value) { + message += `* ${key}\n` + } + }) + + setLoadingSpinnerDesc('Sending report') + const isSent = await sendDMUsingRandomKey(message, reportingPubkey!) + if (isSent) handleClose() + } + setIsLoading(false) + } + + return ( + <> + {isLoading && } + + + + + + + Report Post + + + + + + + + + + + + Why are you reporting this? + + + + Actually CP + + handleCheckboxChange('actuallyCP')} + /> + + + Spam + handleCheckboxChange('spam')} + /> + + + Scam + handleCheckboxChange('scam')} + /> + + + + Not a game mod + + handleCheckboxChange('notAGameMod')} + /> + + + + Stolen game mod + + handleCheckboxChange('stolenGameMod')} + /> + + + + Wasn't tagged NSFW + + handleCheckboxChange('wasntTaggedNSFW')} + /> + + + + Other reason + + handleCheckboxChange('otherReason')} + /> + + + + Submit Report + + + + + + + + > ) } diff --git a/src/pages/mods.tsx b/src/pages/mods.tsx index 7243a8c..e61be15 100644 --- a/src/pages/mods.tsx +++ b/src/pages/mods.tsx @@ -11,7 +11,7 @@ import { useNavigate } from 'react-router-dom' import { LoadingSpinner } from '../components/LoadingSpinner' import { ModCard } from '../components/ModCard' import { MetadataController } from '../controllers' -import { useDidMount } from '../hooks' +import { useAppSelector, useDidMount } from '../hooks' import { getModsInnerPageRoute } from '../routes' import '../styles/filters.css' import '../styles/pagination.css' @@ -36,7 +36,8 @@ enum NSFWFilter { enum ModeratedFilter { Moderated = 'Moderated', - Unmoderated = 'Unmoderated' + Unmoderated = 'Unmoderated', + Unmoderated_Fully = 'Unmoderated Fully' } interface FilterOptions { @@ -56,18 +57,36 @@ export const ModsPage = () => { source: window.location.host, moderated: ModeratedFilter.Moderated }) - const [muteLists, setMuteLists] = useState({ - authors: [], - eventIds: [] + const [muteLists, setMuteLists] = useState<{ + admin: MuteLists + user: MuteLists + }>({ + admin: { + authors: [], + replaceableEvents: [] + }, + user: { + authors: [], + replaceableEvents: [] + } }) + const [nsfwList, setNSFWList] = useState([]) const [page, setPage] = useState(1) + const userState = useAppSelector((state) => state.user) + useDidMount(async () => { + const pubkey = userState.user?.pubkey as string | undefined + const metadataController = await MetadataController.getInstance() - metadataController.getAdminsMuteLists().then((lists) => { + metadataController.getMuteLists(pubkey).then((lists) => { setMuteLists(lists) }) + + metadataController.getNSFWList().then((list) => { + setNSFWList(list) + }) }) useEffect(() => { @@ -85,7 +104,7 @@ export const ModsPage = () => { setIsFetching(true) const until = - mods.length > 0 ? mods[mods.length - 1].edited_at - 1 : undefined + mods.length > 0 ? mods[mods.length - 1].published_at - 1 : undefined fetchMods(filterOptions.source, until) .then((res) => { @@ -100,7 +119,7 @@ export const ModsPage = () => { const handlePrev = useCallback(() => { setIsFetching(true) - const since = mods.length > 0 ? mods[0].edited_at + 1 : undefined + const since = mods.length > 0 ? mods[0].published_at + 1 : undefined fetchMods(filterOptions.source, undefined, since) .then((res) => { @@ -118,39 +137,54 @@ export const ModsPage = () => { switch (filterOptions.nsfw) { case NSFWFilter.Hide_NSFW: // If 'Hide_NSFW' is selected, filter out NSFW mods - return mods.filter((mod) => !mod.nsfw) + return mods.filter((mod) => !mod.nsfw && !nsfwList.includes(mod.aTag)) case NSFWFilter.Show_NSFW: // If 'Show_NSFW' is selected, return all mods (no filtering) return mods case NSFWFilter.Only_NSFW: // If 'Only_NSFW' is selected, filter to show only NSFW mods - return mods.filter((mod) => mod.nsfw) + return mods.filter((mod) => mod.nsfw || nsfwList.includes(mod.aTag)) } } let filtered = nsfwFilter(mods) + const isAdmin = userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB + const isUnmoderatedFully = + filterOptions.moderated === ModeratedFilter.Unmoderated_Fully + + // Only apply filtering if the user is not an admin or the admin has not selected "Unmoderated Fully" + if (!(isAdmin && isUnmoderatedFully)) { + filtered = filtered.filter( + (mod) => + !muteLists.admin.authors.includes(mod.author) && + !muteLists.admin.replaceableEvents.includes(mod.aTag) + ) + } + if (filterOptions.moderated === ModeratedFilter.Moderated) { filtered = filtered.filter( (mod) => - !muteLists.authors.includes(mod.author) && - !muteLists.eventIds.includes(mod.id) + !muteLists.user.authors.includes(mod.author) && + !muteLists.user.replaceableEvents.includes(mod.aTag) ) } if (filterOptions.sort === SortBy.Latest) { - filtered.sort((a, b) => b.edited_at - a.edited_at) + filtered.sort((a, b) => b.published_at - a.published_at) } else if (filterOptions.sort === SortBy.Oldest) { - filtered.sort((a, b) => a.edited_at - b.edited_at) + filtered.sort((a, b) => a.published_at - b.published_at) } return filtered }, [ + userState.user?.npub, filterOptions.sort, filterOptions.moderated, filterOptions.nsfw, mods, - muteLists + muteLists, + nsfwList ]) return ( @@ -167,25 +201,26 @@ export const ModsPage = () => { - {filteredModList.map((mod) => ( - - navigate( - getModsInnerPageRoute( - nip19.naddrEncode({ - identifier: mod.aTag, - pubkey: mod.author, - kind: kinds.ClassifiedListing - }) - ) - ) - } - /> - ))} + {filteredModList.map((mod) => { + const route = getModsInnerPageRoute( + nip19.naddrEncode({ + identifier: mod.aTag, + pubkey: mod.author, + kind: kinds.ClassifiedListing + }) + ) + + return ( + navigate(route)} + /> + ) + })} @@ -239,6 +274,8 @@ type FiltersProps = { const Filters = React.memo( ({ filterOptions, setFilterOptions }: FiltersProps) => { + const userState = useAppSelector((state) => state.user) + return ( @@ -282,20 +319,30 @@ const Filters = React.memo( {filterOptions.moderated} - {Object.values(ModeratedFilter).map((item, index) => ( - - setFilterOptions((prev) => ({ - ...prev, - moderated: item - })) - } - > - {item} - - ))} + {Object.values(ModeratedFilter).map((item, index) => { + if (item === ModeratedFilter.Unmoderated_Fully) { + const isAdmin = + userState.user?.npub === + import.meta.env.VITE_REPORTING_NPUB + + if (!isAdmin) return null + } + + return ( + + setFilterOptions((prev) => ({ + ...prev, + moderated: item + })) + } + > + {item} + + ) + })} diff --git a/src/styles/author.css b/src/styles/author.css index 8fe56dc..98a5b11 100644 --- a/src/styles/author.css +++ b/src/styles/author.css @@ -56,6 +56,8 @@ } .IBMSMSMSSS_Author_Top_Icon { + min-width: 16px; + min-height: 16px; } .IBMSMSMSSS_Author_Top_Address { diff --git a/src/types/mod.ts b/src/types/mod.ts index 06650af..b161187 100644 --- a/src/types/mod.ts +++ b/src/types/mod.ts @@ -32,5 +32,5 @@ export interface ModDetails extends Omit { export interface MuteLists { authors: string[] - eventIds: string[] + replaceableEvents: string[] } diff --git a/src/utils/mod.ts b/src/utils/mod.ts index fba673d..ac2a9cc 100644 --- a/src/utils/mod.ts +++ b/src/utils/mod.ts @@ -167,8 +167,6 @@ export const fetchMods = async ( return RelayController.getInstance() .fetchEvents(filter, []) // Pass the filter and an empty array of options .then((events) => { - console.log('events :>> ', events) - // Convert the fetched events into a list of mods const modList = constructModListFromEvents(events) return modList // Return the list of mods diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index 9d1c7c8..ef03678 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -1,4 +1,16 @@ -import { nip19, Event } from 'nostr-tools' +import { + Event, + finalizeEvent, + generateSecretKey, + getPublicKey, + kinds, + nip04, + nip19, + UnsignedEvent +} from 'nostr-tools' +import { toast } from 'react-toastify' +import { RelayController } from '../controllers' +import { log, LogType } from './utils' /** * Get the current time in seconds since the Unix epoch (January 1, 1970). @@ -121,3 +133,107 @@ export const extractZapAmount = (event: Event): number => { // Return 0 if the zap amount cannot be determined return 0 } + +/** + * Signs and publishes an event to user's relays. + * + * @param unsignedEvent - The event object which needs to be signed before publishing. + * @returns - A promise that resolves to boolean indicating whether the event was successfully signed and published + */ +export const signAndPublish = async (unsignedEvent: UnsignedEvent) => { + // Sign the event. This returns a signed event or null if signing fails. + const signedEvent = await window.nostr + ?.signEvent(unsignedEvent) + .then((event) => event as Event) + .catch((err) => { + // If signing the event fails, display an error toast and log the error. + toast.error('Failed to sign the event!') + log(true, LogType.Error, 'Failed to sign the event!', err) + return null + }) + + // If the event couldn't be signed, exit the function and return null. + if (!signedEvent) return false + + // Publish the signed event to the relays using the RelayController. + // This returns an array of relay URLs where the event was successfully published. + const publishedOnRelays = await RelayController.getInstance().publish( + signedEvent as Event + ) + + // Handle cases where publishing to the relays failed + if (publishedOnRelays.length === 0) { + // Display an error toast if the event could not be published to any relay. + toast.error('Failed to publish event on any relay') + return false + } + + // Display a success toast with the list of relays where the event was successfully published. + toast.success( + `Event published successfully to the following relays\n\n${publishedOnRelays.join( + '\n' + )}` + ) + + return true +} + +/** + * Sends an encrypted direct message (DM) to a receiver using a randomly generated secret key. + * + * @param message - The plaintext message content to be sent. + * @param receiver - The public key of the receiver to whom the message is being sent. + * @returns - A promise that resolves to true if the message was successfully sent, or false if an error occurred. + */ +export const sendDMUsingRandomKey = async ( + message: string, + receiver: string +) => { + // Generate a random secret key for encrypting the message + const secretKey = generateSecretKey() + + // Encrypt the message using the generated secret key and the receiver's public key + const encryptedMessage = await nip04 + .encrypt(secretKey, receiver, message) + .catch((err) => { + // If encryption fails, display an error toast + toast.error( + `An error occurred in encrypting message content: ${err.message || err}` + ) + return null + }) + + // If encryption failed, exit the function and return false + if (!encryptedMessage) return false + + // Construct the unsigned event containing the encrypted message and relevant metadata + const unsignedEvent: UnsignedEvent = { + pubkey: getPublicKey(secretKey), + kind: kinds.EncryptedDirectMessage, + created_at: now(), + tags: [['p', receiver]], + content: encryptedMessage + } + + // Finalize and sign the event using the generated secret key + const signedEvent = finalizeEvent(unsignedEvent, secretKey) + + // Publish the signed event (the encrypted DM) to the relays + const publishedOnRelays = await RelayController.getInstance().publishDM( + signedEvent, + receiver + ) + + // Handle cases where publishing to the relays failed + if (publishedOnRelays.length === 0) { + // Display an error toast if the event could not be published to any relay + toast.error('Failed to publish encrypted direct message on any relay') + return false + } + + // Display a success toast if the event was successfully published to one or more relays + toast.success(`Report successfully submitted!`) + + // Return true indicating that the DM was successfully sent + return true +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index a399374..141f56d 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -3,6 +3,7 @@ interface ImportMetaEnv { readonly VITE_APP_RELAY: string readonly VITE_ADMIN_NPUBS: string + readonly VITE_REPORTING_NPUB: string // more env variables... }
- Mod for: - - {game} - -
+ Mod for: + + {game} + +