Merge pull request 'feat: added the ability to report and block posts' (#25) from block-report into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s

Reviewed-on: #25
This commit is contained in:
s 2024-08-28 17:05:21 +00:00
commit 4d64c33597
10 changed files with 844 additions and 118 deletions

View File

@ -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= <A comma separated list of npubs>
# A dedicated npub used for reporting mods, blogs, profile and etc.
VITE_REPORTING_NPUB= <npub1...>

View File

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

View File

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

View File

@ -19,6 +19,7 @@ export class MetadataController {
private usersMetadata = new Map<string, UserProfile>()
public adminNpubs: string[]
public adminRelays = new Set<string>()
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,13 +131,18 @@ export class MetadataController {
return ndkRelayList[userRelaysType]
}
public getAdminsMuteLists = async (): Promise<MuteLists> => {
// Create a Set to collect all unique muted authors
public getMuteLists = async (pubkey?: string): Promise<MuteLists> => {
// Create sets to collect all unique muted authors and replaceable event Identifiers
const mutedAuthors = new Set<string>()
const mutedEvents = new Set<string>()
// construct an array of npubs with dedicated reporting npub and provided pubkey
const npubs = [this.reportingNpub]
if (pubkey) npubs.push(pubkey)
// Create an array of promises to fetch mute lists for each npub
const promises = this.adminNpubs.map(async (npub) => {
const promises = npubs.map(async (npub) => {
const hexKey = npubToHex(npub)
if (!hexKey) return
@ -148,9 +155,10 @@ export class MetadataController {
const list = NDKList.from(muteListEvent)
list.items.forEach((item) => {
// Add muted authors to the Set directly
if (item[0] === 'p') {
mutedAuthors.add(item[1])
} else if (item[0] === 'a') {
mutedEvents.add(item[1])
}
})
}
@ -160,7 +168,46 @@ export class MetadataController {
return {
authors: Array.from(mutedAuthors),
eventIds: []
replaceableEvents: Array.from(mutedEvents)
}
}
/**
* 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<string[]>} - A promise that resolves to an array of NSFW post identifiers (e.g., URLs or IDs).
*/
public getNSFWList = async (): Promise<string[]> => {
// 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
}
}

View File

@ -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<string[]> => {
// 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

View File

@ -2,7 +2,7 @@ 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 { Filter, kinds, nip19, UnsignedEvent } from 'nostr-tools'
import { Dispatch, SetStateAction, useCallback, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { toast } from 'react-toastify'
@ -13,6 +13,7 @@ import { ZapButtons, ZapPresets, ZapQR } from '../components/Zap'
import {
MetadataController,
RelayController,
UserRelaysType,
ZapController
} from '../controllers'
import { useAppSelector, useDidMount } from '../hooks'
@ -37,7 +38,11 @@ import {
getFilenameFromUrl,
log,
LogType,
unformatNumber
now,
npubToHex,
sendDMUsingRandomKey,
unformatNumber,
signAndPublish
} from '../utils'
export const InnerModPage = () => {
@ -114,6 +119,7 @@ export const InnerModPage = () => {
naddr={naddr!}
game={modData.game}
author={modData.author}
aTag={modData.aTag}
/>
<Body
featuredImageUrl={modData.featuredImageUrl}
@ -197,47 +203,214 @@ type GameProps = {
naddr: string
game: string
author: string
aTag: string
}
const Game = ({ naddr, game, author }: GameProps) => {
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 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)
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')
await signAndPublish(unsignedEvent)
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)
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')
await signAndPublish(unsignedEvent)
setIsLoading(false)
}
const isAdmin =
userState.user?.npub &&
userState.user.npub === import.meta.env.VITE_REPORTING_NPUB
return (
<div className='IBMSMSMBSSModFor'>
<p className='IBMSMSMBSSModForPara'>
Mod for:&nbsp;
<a className='IBMSMSMBSSModForLink' href='search.html'>
{game}
</a>
</p>
<div className='dropdown dropdownMain' style={{ flexGrow: 'unset' }}>
<button
className='btn btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
style={{
borderRadius: '5px',
background: 'unset',
padding: '5px'
}}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-192 0 512 512'
width='1em'
height='1em'
fill='currentColor'
<>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
<div className='IBMSMSMBSSModFor'>
<p className='IBMSMSMBSSModForPara'>
Mod for:&nbsp;
<a className='IBMSMSMBSSModForLink' href='search.html'>
{game}
</a>
</p>
<div className='dropdown dropdownMain' style={{ flexGrow: 'unset' }}>
<button
className='btn btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
style={{
borderRadius: '5px',
background: 'unset',
padding: '5px'
}}
>
<path d='M64 360C94.93 360 120 385.1 120 416C120 446.9 94.93 472 64 472C33.07 472 8 446.9 8 416C8 385.1 33.07 360 64 360zM64 200C94.93 200 120 225.1 120 256C120 286.9 94.93 312 64 312C33.07 312 8 286.9 8 256C8 225.1 33.07 200 64 200zM64 152C33.07 152 8 126.9 8 96C8 65.07 33.07 40 64 40C94.93 40 120 65.07 120 96C120 126.9 94.93 152 64 152z'></path>
</svg>
</button>
<div className={`dropdown-menu dropdown-menu-end dropdownMainMenu`}>
{userState.auth && userState.user?.pubkey === author && (
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-192 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M64 360C94.93 360 120 385.1 120 416C120 446.9 94.93 472 64 472C33.07 472 8 446.9 8 416C8 385.1 33.07 360 64 360zM64 200C94.93 200 120 225.1 120 256C120 286.9 94.93 312 64 312C33.07 312 8 286.9 8 256C8 225.1 33.07 200 64 200zM64 152C33.07 152 8 126.9 8 96C8 65.07 33.07 40 64 40C94.93 40 120 65.07 120 96C120 126.9 94.93 152 64 152z'></path>
</svg>
</button>
<div className={`dropdown-menu dropdown-menu-end dropdownMainMenu`}>
{userState.auth && userState.user?.pubkey === author && (
<a
className='dropdown-item dropdownMainMenuItem'
onClick={() => navigate(getModsEditPageRoute(naddr))}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M362.7 19.32C387.7-5.678 428.3-5.678 453.3 19.32L492.7 58.75C517.7 83.74 517.7 124.3 492.7 149.3L444.3 197.7L314.3 67.72L362.7 19.32zM421.7 220.3L188.5 453.4C178.1 463.8 165.2 471.5 151.1 475.6L30.77 511C22.35 513.5 13.24 511.2 7.03 504.1C.8198 498.8-1.502 489.7 .976 481.2L36.37 360.9C40.53 346.8 48.16 333.9 58.57 323.5L291.7 90.34L421.7 220.3z'></path>
</svg>
Edit
</a>
)}
<a
className='dropdown-item dropdownMainMenuItem'
onClick={() => navigate(getModsEditPageRoute(naddr))}
onClick={() => {
copyTextToClipboard(window.location.href).then((isCopied) => {
if (isCopied) toast.success('Url copied to clipboard!')
})
}}
>
<svg
xmlns='http://www.w3.org/2000/svg'
@ -247,78 +420,314 @@ const Game = ({ naddr, game, author }: GameProps) => {
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M362.7 19.32C387.7-5.678 428.3-5.678 453.3 19.32L492.7 58.75C517.7 83.74 517.7 124.3 492.7 149.3L444.3 197.7L314.3 67.72L362.7 19.32zM421.7 220.3L188.5 453.4C178.1 463.8 165.2 471.5 151.1 475.6L30.77 511C22.35 513.5 13.24 511.2 7.03 504.1C.8198 498.8-1.502 489.7 .976 481.2L36.37 360.9C40.53 346.8 48.16 333.9 58.57 323.5L291.7 90.34L421.7 220.3z'></path>
<path d='M384 96L384 0h-112c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48H464c26.51 0 48-21.49 48-48V128h-95.1C398.4 128 384 113.6 384 96zM416 0v96h96L416 0zM192 352V128h-144c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48h192c26.51 0 48-21.49 48-48L288 416h-32C220.7 416 192 387.3 192 352z'></path>
</svg>
Edit
Copy URL
</a>
)}
<a
className='dropdown-item dropdownMainMenuItem'
onClick={() => {
copyTextToClipboard(window.location.href).then((isCopied) => {
if (isCopied) toast.success('Url copied to clipboard!')
})
}}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
<a className='dropdown-item dropdownMainMenuItem' href='#'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M503.7 226.2l-176 151.1c-15.38 13.3-39.69 2.545-39.69-18.16V272.1C132.9 274.3 66.06 312.8 111.4 457.8c5.031 16.09-14.41 28.56-28.06 18.62C39.59 444.6 0 383.8 0 322.3c0-152.2 127.4-184.4 288-186.3V56.02c0-20.67 24.28-31.46 39.69-18.16l176 151.1C514.8 199.4 514.8 216.6 503.7 226.2z'></path>
</svg>
Share
</a>
<a
className='dropdown-item dropdownMainMenuItem'
id='reportPost'
onClick={() => setShowReportPopUp(true)}
>
<path d='M384 96L384 0h-112c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48H464c26.51 0 48-21.49 48-48V128h-95.1C398.4 128 384 113.6 384 96zM416 0v96h96L416 0zM192 352V128h-144c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48h192c26.51 0 48-21.49 48-48L288 416h-32C220.7 416 192 387.3 192 352z'></path>
</svg>
Copy URL
</a>
<a className='dropdown-item dropdownMainMenuItem' href='#'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M506.3 417l-213.3-364c-16.33-28-57.54-28-73.98 0l-213.2 364C-10.59 444.9 9.849 480 42.74 480h426.6C502.1 480 522.6 445 506.3 417zM232 168c0-13.25 10.75-24 24-24S280 154.8 280 168v128c0 13.25-10.75 24-23.1 24S232 309.3 232 296V168zM256 416c-17.36 0-31.44-14.08-31.44-31.44c0-17.36 14.07-31.44 31.44-31.44s31.44 14.08 31.44 31.44C287.4 401.9 273.4 416 256 416z'></path>
</svg>
Report
</a>
<a
className='dropdown-item dropdownMainMenuItem'
onClick={handleBlock}
>
<path d='M503.7 226.2l-176 151.1c-15.38 13.3-39.69 2.545-39.69-18.16V272.1C132.9 274.3 66.06 312.8 111.4 457.8c5.031 16.09-14.41 28.56-28.06 18.62C39.59 444.6 0 383.8 0 322.3c0-152.2 127.4-184.4 288-186.3V56.02c0-20.67 24.28-31.46 39.69-18.16l176 151.1C514.8 199.4 514.8 216.6 503.7 226.2z'></path>
</svg>
Share
</a>
<a
className='dropdown-item dropdownMainMenuItem'
id='reportPost'
href='#'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M506.3 417l-213.3-364c-16.33-28-57.54-28-73.98 0l-213.2 364C-10.59 444.9 9.849 480 42.74 480h426.6C502.1 480 522.6 445 506.3 417zM232 168c0-13.25 10.75-24 24-24S280 154.8 280 168v128c0 13.25-10.75 24-23.1 24S232 309.3 232 296V168zM256 416c-17.36 0-31.44-14.08-31.44-31.44c0-17.36 14.07-31.44 31.44-31.44s31.44 14.08 31.44 31.44C287.4 401.9 273.4 416 256 416z'></path>
</svg>
Report
</a>
<a className='dropdown-item dropdownMainMenuItem' href='#'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M323.5 51.25C302.8 70.5 284 90.75 267.4 111.1C240.1 73.62 206.2 35.5 168 0C69.75 91.12 0 210 0 281.6C0 408.9 100.2 512 224 512s224-103.1 224-230.4C448 228.4 396 118.5 323.5 51.25zM304.1 391.9C282.4 407 255.8 416 226.9 416c-72.13 0-130.9-47.73-130.9-125.2c0-38.63 24.24-72.64 65.13-83.3c10.14-2.656 19.94 4.78 19.94 15.27c0 6.941-4.469 13.16-11.16 15.19c-17.5 4.578-34.41 23.94-34.41 52.84c0 50.81 39.31 94.81 91.41 94.81c24.66 0 45.22-6.5 63.19-18.75c11.75-8 27.91 3.469 23.91 16.69C314.6 384.7 309.8 388.4 304.1 391.9z'></path>
</svg>
Block Post
</a>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M323.5 51.25C302.8 70.5 284 90.75 267.4 111.1C240.1 73.62 206.2 35.5 168 0C69.75 91.12 0 210 0 281.6C0 408.9 100.2 512 224 512s224-103.1 224-230.4C448 228.4 396 118.5 323.5 51.25zM304.1 391.9C282.4 407 255.8 416 226.9 416c-72.13 0-130.9-47.73-130.9-125.2c0-38.63 24.24-72.64 65.13-83.3c10.14-2.656 19.94 4.78 19.94 15.27c0 6.941-4.469 13.16-11.16 15.19c-17.5 4.578-34.41 23.94-34.41 52.84c0 50.81 39.31 94.81 91.41 94.81c24.66 0 45.22-6.5 63.19-18.75c11.75-8 27.91 3.469 23.91 16.69C314.6 384.7 309.8 388.4 304.1 391.9z'></path>
</svg>
Block Post
</a>
{isAdmin && (
<a
className='dropdown-item dropdownMainMenuItem'
onClick={handleBlockNSFW}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M323.5 51.25C302.8 70.5 284 90.75 267.4 111.1C240.1 73.62 206.2 35.5 168 0C69.75 91.12 0 210 0 281.6C0 408.9 100.2 512 224 512s224-103.1 224-230.4C448 228.4 396 118.5 323.5 51.25zM304.1 391.9C282.4 407 255.8 416 226.9 416c-72.13 0-130.9-47.73-130.9-125.2c0-38.63 24.24-72.64 65.13-83.3c10.14-2.656 19.94 4.78 19.94 15.27c0 6.941-4.469 13.16-11.16 15.19c-17.5 4.578-34.41 23.94-34.41 52.84c0 50.81 39.31 94.81 91.41 94.81c24.66 0 45.22-6.5 63.19-18.75c11.75-8 27.91 3.469 23.91 16.69C314.6 384.7 309.8 388.4 304.1 391.9z'></path>
</svg>
Block Post NSFW
</a>
)}
</div>
</div>
</div>
</div>
{showReportPopUp && (
<ReportPopup
aTag={aTag}
handleClose={() => 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
})
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 && <LoadingSpinner desc={loadingSpinnerDesc} />}
<div className='popUpMain'>
<div className='ContainerMain'>
<div className='popUpMainCardWrapper'>
<div className='popUpMainCard popUpMainCardQR'>
<div className='popUpMainCardTop'>
<div className='popUpMainCardTopInfo'>
<h3>Report Post</h3>
</div>
<div className='popUpMainCardTopClose' onClick={handleClose}>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-96 0 512 512'
width='1em'
height='1em'
fill='currentColor'
style={{ zIndex: 1 }}
>
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
</svg>
</div>
</div>
<div className='pUMCB_Zaps'>
<div className='pUMCB_ZapsInside'>
<div className='inputLabelWrapperMain'>
<label
className='form-label labelMain'
style={{ fontWeight: 'bold' }}
>
Why are you reporting this?
</label>
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt'>
<label className='form-label labelMain'>
Actually CP
</label>
<input
type='checkbox'
className='CheckboxMain'
name='reportOption'
checked={selectedOptions.actuallyCP}
onChange={() => handleCheckboxChange('actuallyCP')}
/>
</div>
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt'>
<label className='form-label labelMain'>Spam</label>
<input
type='checkbox'
className='CheckboxMain'
name='reportOption'
checked={selectedOptions.spam}
onChange={() => handleCheckboxChange('spam')}
/>
</div>
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt'>
<label className='form-label labelMain'>Scam</label>
<input
type='checkbox'
className='CheckboxMain'
name='reportOption'
checked={selectedOptions.scam}
onChange={() => handleCheckboxChange('scam')}
/>
</div>
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt'>
<label className='form-label labelMain'>
Not a game mod
</label>
<input
type='checkbox'
className='CheckboxMain'
name='reportOption'
checked={selectedOptions.notAGameMod}
onChange={() => handleCheckboxChange('notAGameMod')}
/>
</div>
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt'>
<label className='form-label labelMain'>
Stolen game mod
</label>
<input
type='checkbox'
className='CheckboxMain'
name='reportOption'
checked={selectedOptions.stolenGameMod}
onChange={() => handleCheckboxChange('stolenGameMod')}
/>
</div>
</div>
<button
className='btn btnMain pUMCB_Report'
type='button'
style={{ width: '100%' }}
onClick={handleSubmit}
>
Submit Report
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</>
)
}

View File

@ -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'
@ -58,16 +58,25 @@ export const ModsPage = () => {
})
const [muteLists, setMuteLists] = useState<MuteLists>({
authors: [],
eventIds: []
replaceableEvents: []
})
const [nsfwList, setNSFWList] = useState<string[]>([])
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(() => {
@ -118,13 +127,13 @@ 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))
}
}
@ -134,7 +143,7 @@ export const ModsPage = () => {
filtered = filtered.filter(
(mod) =>
!muteLists.authors.includes(mod.author) &&
!muteLists.eventIds.includes(mod.id)
!muteLists.replaceableEvents.includes(mod.aTag)
)
}
@ -150,7 +159,8 @@ export const ModsPage = () => {
filterOptions.moderated,
filterOptions.nsfw,
mods,
muteLists
muteLists,
nsfwList
])
return (

View File

@ -32,5 +32,5 @@ export interface ModDetails extends Omit<ModFormState, 'tags'> {
export interface MuteLists {
authors: string[]
eventIds: string[]
replaceableEvents: string[]
}

View File

@ -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 an array of relay URLs where the event was successfully published, or null if the operation failed.
*/
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
}

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

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