refactor: mod page, add generic report popup, repost option
This commit is contained in:
parent
a241f90269
commit
c55dc03382
@ -15,9 +15,14 @@ import {
|
|||||||
signAndPublish
|
signAndPublish
|
||||||
} from 'utils'
|
} from 'utils'
|
||||||
|
|
||||||
export const blogReportRouteAction =
|
export const reportRouteAction =
|
||||||
(ndkContext: NDKContextType) =>
|
(ndkContext: NDKContextType) =>
|
||||||
async ({ params, request }: ActionFunctionArgs) => {
|
async ({ params, request }: ActionFunctionArgs) => {
|
||||||
|
// Check which post type is reported
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const isModReport = url.pathname.startsWith('/mod/')
|
||||||
|
const isBlogReport = url.pathname.startsWith('/blog/')
|
||||||
|
const title = isModReport ? 'Mod' : isBlogReport ? 'Blog' : 'Post'
|
||||||
const requestData = await request.formData()
|
const requestData = await request.formData()
|
||||||
const { naddr } = params
|
const { naddr } = params
|
||||||
if (!naddr) {
|
if (!naddr) {
|
||||||
@ -30,7 +35,12 @@ export const blogReportRouteAction =
|
|||||||
try {
|
try {
|
||||||
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
|
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
|
||||||
const { identifier, kind, pubkey } = decoded.data
|
const { identifier, kind, pubkey } = decoded.data
|
||||||
|
|
||||||
aTag = `${kind}:${pubkey}:${identifier}`
|
aTag = `${kind}:${pubkey}:${identifier}`
|
||||||
|
|
||||||
|
if (isModReport) {
|
||||||
|
aTag = identifier
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log(true, LogType.Error, 'Failed to decode naddr')
|
log(true, LogType.Error, 'Failed to decode naddr')
|
||||||
return false
|
return false
|
||||||
@ -82,7 +92,7 @@ export const blogReportRouteAction =
|
|||||||
const alreadyExists =
|
const alreadyExists =
|
||||||
tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== -1
|
tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== -1
|
||||||
if (alreadyExists) {
|
if (alreadyExists) {
|
||||||
toast.warn(`Blog reference is already in user's mute list`)
|
toast.warn(`${title} reference is already in user's mute list`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
tags.push(['a', aTag])
|
tags.push(['a', aTag])
|
||||||
@ -109,10 +119,12 @@ export const blogReportRouteAction =
|
|||||||
log(
|
log(
|
||||||
true,
|
true,
|
||||||
LogType.Error,
|
LogType.Error,
|
||||||
'Could not get pubkey for reporting blog!',
|
`Could not get pubkey for reporting ${title.toLowerCase()}!`,
|
||||||
error
|
error
|
||||||
)
|
)
|
||||||
toast.error('Could not get pubkey for reporting blog!')
|
toast.error(
|
||||||
|
`Could not get pubkey for reporting ${title.toLowerCase()}!`
|
||||||
|
)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -122,7 +134,7 @@ export const blogReportRouteAction =
|
|||||||
ndkContext.publish
|
ndkContext.publish
|
||||||
)
|
)
|
||||||
return { isSent: isUpdated }
|
return { isSent: isUpdated }
|
||||||
} else {
|
} else if (reportingPubkey) {
|
||||||
const href = window.location.href
|
const href = window.location.href
|
||||||
let message = `I'd like to report ${href} due to following reasons:\n`
|
let message = `I'd like to report ${href} due to following reasons:\n`
|
||||||
Object.entries(formSubmit).forEach(([key, value]) => {
|
Object.entries(formSubmit).forEach(([key, value]) => {
|
||||||
@ -133,14 +145,26 @@ export const blogReportRouteAction =
|
|||||||
try {
|
try {
|
||||||
const isSent = await sendDMUsingRandomKey(
|
const isSent = await sendDMUsingRandomKey(
|
||||||
message,
|
message,
|
||||||
reportingPubkey!,
|
reportingPubkey,
|
||||||
ndkContext.ndk,
|
ndkContext.ndk,
|
||||||
ndkContext.publish
|
ndkContext.publish
|
||||||
)
|
)
|
||||||
return { isSent: isSent }
|
return { isSent: isSent }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log(true, LogType.Error, 'Failed to send a blog report', error)
|
log(
|
||||||
|
true,
|
||||||
|
LogType.Error,
|
||||||
|
`Failed to send a ${title.toLowerCase()} report`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log(
|
||||||
|
true,
|
||||||
|
LogType.Error,
|
||||||
|
`Failed to send a ${title.toLowerCase()} report: VITE_REPORTING_NPUB missing`
|
||||||
|
)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
@ -1,3 +1,4 @@
|
|||||||
|
import { useNavigation } from 'react-router-dom'
|
||||||
import styles from '../../styles/loadingSpinner.module.scss'
|
import styles from '../../styles/loadingSpinner.module.scss'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -16,3 +17,14 @@ export const LoadingSpinner = (props: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const RouterLoadingSpinner = () => {
|
||||||
|
const navigation = useNavigation()
|
||||||
|
|
||||||
|
if (navigation.state === 'idle') return null
|
||||||
|
|
||||||
|
const desc =
|
||||||
|
navigation.state.charAt(0).toUpperCase() + navigation.state.slice(1)
|
||||||
|
|
||||||
|
return <LoadingSpinner desc={`${desc}...`} />
|
||||||
|
}
|
||||||
|
@ -1,22 +1,23 @@
|
|||||||
import { useFetcher } from 'react-router-dom'
|
import { useFetcher } from 'react-router-dom'
|
||||||
import { CheckboxFieldUncontrolled } from 'components/Inputs'
|
import { CheckboxFieldUncontrolled } from 'components/Inputs'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
|
import { ReportReason } from 'types/report'
|
||||||
|
import { LoadingSpinner } from './LoadingSpinner'
|
||||||
|
|
||||||
type ReportPopupProps = {
|
type ReportPopupProps = {
|
||||||
|
openedAt: number
|
||||||
|
reasons: ReportReason[]
|
||||||
handleClose: () => void
|
handleClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const BLOG_REPORT_REASONS = [
|
export const ReportPopup = ({
|
||||||
{ label: 'Actually CP', key: 'actuallyCP' },
|
openedAt,
|
||||||
{ label: 'Spam', key: 'spam' },
|
reasons,
|
||||||
{ label: 'Scam', key: 'scam' },
|
handleClose
|
||||||
{ label: 'Malware', key: 'malware' },
|
}: ReportPopupProps) => {
|
||||||
{ label: `Wasn't tagged NSFW`, key: 'wasntTaggedNSFW' },
|
// Use openedAt to allow for multiple reports
|
||||||
{ label: 'Other', key: 'otherReason' }
|
// by default, fetcher will remember the data
|
||||||
]
|
const fetcher = useFetcher({ key: openedAt.toString() })
|
||||||
|
|
||||||
export const ReportPopup = ({ handleClose }: ReportPopupProps) => {
|
|
||||||
const fetcher = useFetcher()
|
|
||||||
|
|
||||||
// Close automatically if action succeeds
|
// Close automatically if action succeeds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -30,6 +31,7 @@ export const ReportPopup = ({ handleClose }: ReportPopupProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{fetcher.state !== 'idle' && <LoadingSpinner desc={''} />}
|
||||||
<div className='popUpMain'>
|
<div className='popUpMain'>
|
||||||
<div className='ContainerMain'>
|
<div className='ContainerMain'>
|
||||||
<div className='popUpMainCardWrapper'>
|
<div className='popUpMainCardWrapper'>
|
||||||
@ -64,7 +66,7 @@ export const ReportPopup = ({ handleClose }: ReportPopupProps) => {
|
|||||||
>
|
>
|
||||||
Why are you reporting this?
|
Why are you reporting this?
|
||||||
</label>
|
</label>
|
||||||
{BLOG_REPORT_REASONS.map((r) => (
|
{reasons.map((r) => (
|
||||||
<CheckboxFieldUncontrolled
|
<CheckboxFieldUncontrolled
|
||||||
key={r.key}
|
key={r.key}
|
||||||
label={r.label}
|
label={r.label}
|
@ -22,7 +22,16 @@ import { BlogCard } from 'components/BlogCard'
|
|||||||
import { copyTextToClipboard } from 'utils'
|
import { copyTextToClipboard } from 'utils'
|
||||||
import { toast } from 'react-toastify'
|
import { toast } from 'react-toastify'
|
||||||
import { useAppSelector, useBodyScrollDisable } from 'hooks'
|
import { useAppSelector, useBodyScrollDisable } from 'hooks'
|
||||||
import { ReportPopup } from './report'
|
import { ReportPopup } from 'components/ReportPopup'
|
||||||
|
|
||||||
|
const BLOG_REPORT_REASONS = [
|
||||||
|
{ label: 'Actually CP', key: 'actuallyCP' },
|
||||||
|
{ label: 'Spam', key: 'spam' },
|
||||||
|
{ label: 'Scam', key: 'scam' },
|
||||||
|
{ label: 'Malware', key: 'malware' },
|
||||||
|
{ label: `Wasn't tagged NSFW`, key: 'wasntTaggedNSFW' },
|
||||||
|
{ label: 'Other', key: 'otherReason' }
|
||||||
|
]
|
||||||
|
|
||||||
export const BlogPage = () => {
|
export const BlogPage = () => {
|
||||||
const { blog, latest, isAddedToNSFW, isBlocked } =
|
const { blog, latest, isAddedToNSFW, isBlocked } =
|
||||||
@ -53,8 +62,8 @@ export const BlogPage = () => {
|
|||||||
[sanitized]
|
[sanitized]
|
||||||
)
|
)
|
||||||
|
|
||||||
const [showReportPopUp, setShowReportPopUp] = useState(false)
|
const [showReportPopUp, setShowReportPopUp] = useState<number>()
|
||||||
useBodyScrollDisable(showReportPopUp)
|
useBodyScrollDisable(!!showReportPopUp)
|
||||||
|
|
||||||
const submit = useSubmit()
|
const submit = useSubmit()
|
||||||
const handleBlock = () => {
|
const handleBlock = () => {
|
||||||
@ -190,7 +199,7 @@ export const BlogPage = () => {
|
|||||||
<a
|
<a
|
||||||
className='dropdown-item dropdownMainMenuItem'
|
className='dropdown-item dropdownMainMenuItem'
|
||||||
id='reportPost'
|
id='reportPost'
|
||||||
onClick={() => setShowReportPopUp(true)}
|
onClick={() => setShowReportPopUp(Date.now())}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
@ -309,8 +318,12 @@ export const BlogPage = () => {
|
|||||||
{navigation.state !== 'idle' && (
|
{navigation.state !== 'idle' && (
|
||||||
<LoadingSpinner desc={'Loading...'} />
|
<LoadingSpinner desc={'Loading...'} />
|
||||||
)}
|
)}
|
||||||
{showReportPopUp && (
|
{!!showReportPopUp && (
|
||||||
<ReportPopup handleClose={() => setShowReportPopUp(false)} />
|
<ReportPopup
|
||||||
|
openedAt={showReportPopUp}
|
||||||
|
reasons={BLOG_REPORT_REASONS}
|
||||||
|
handleClose={() => setShowReportPopUp(undefined)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
233
src/pages/mod/action.ts
Normal file
233
src/pages/mod/action.ts
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
import { NDKFilter } from '@nostr-dev-kit/ndk'
|
||||||
|
import { NDKContextType } from 'contexts/NDKContext'
|
||||||
|
import { kinds, nip19, UnsignedEvent } from 'nostr-tools'
|
||||||
|
import { ActionFunctionArgs } from 'react-router-dom'
|
||||||
|
import { toast } from 'react-toastify'
|
||||||
|
import { store } from 'store'
|
||||||
|
import { UserRelaysType } from 'types'
|
||||||
|
import {
|
||||||
|
addToCurationSet,
|
||||||
|
CurationSetIdentifiers,
|
||||||
|
log,
|
||||||
|
LogType,
|
||||||
|
now,
|
||||||
|
removeFromCurationSet,
|
||||||
|
signAndPublish
|
||||||
|
} from 'utils'
|
||||||
|
|
||||||
|
export const modRouteAction =
|
||||||
|
(ndkContext: NDKContextType) =>
|
||||||
|
async ({ params, request }: ActionFunctionArgs) => {
|
||||||
|
const { naddr } = params
|
||||||
|
if (!naddr) {
|
||||||
|
log(true, LogType.Error, 'Required naddr.')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode author from naddr
|
||||||
|
let aTag: string | undefined
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
|
||||||
|
|
||||||
|
// We encode mods naddr identifier as a whole aTag
|
||||||
|
const { identifier } = decoded.data
|
||||||
|
aTag = identifier
|
||||||
|
} catch (error) {
|
||||||
|
log(true, LogType.Error, 'Failed to decode naddr')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!aTag) {
|
||||||
|
log(true, LogType.Error, 'Missing #a Tag')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const userState = store.getState().user
|
||||||
|
let hexPubkey: string
|
||||||
|
if (userState.auth && userState.user?.pubkey) {
|
||||||
|
hexPubkey = userState.user.pubkey as string
|
||||||
|
} else {
|
||||||
|
hexPubkey = (await window.nostr?.getPublicKey()) as string
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hexPubkey) {
|
||||||
|
toast.error('Failed to get the pubkey')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin =
|
||||||
|
userState.user?.npub &&
|
||||||
|
userState.user.npub === import.meta.env.VITE_REPORTING_NPUB
|
||||||
|
|
||||||
|
const handleBlock = async () => {
|
||||||
|
// 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: NDKFilter = {
|
||||||
|
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 ndkContext.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) {
|
||||||
|
toast.warn(`Mod reference is already in user's mute list`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
tags.push(['a', aTag])
|
||||||
|
|
||||||
|
unsignedEvent = {
|
||||||
|
pubkey: muteListEvent.pubkey,
|
||||||
|
kind: kinds.Mutelist,
|
||||||
|
content: muteListEvent.content,
|
||||||
|
created_at: now(),
|
||||||
|
tags: [...tags]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
unsignedEvent = {
|
||||||
|
pubkey: hexPubkey,
|
||||||
|
kind: kinds.Mutelist,
|
||||||
|
content: '',
|
||||||
|
created_at: now(),
|
||||||
|
tags: [['a', aTag]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUpdated = await signAndPublish(
|
||||||
|
unsignedEvent,
|
||||||
|
ndkContext.ndk,
|
||||||
|
ndkContext.publish
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!isUpdated) {
|
||||||
|
toast.error("Failed to update user's mute list")
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUnblock = async () => {
|
||||||
|
const filter: NDKFilter = {
|
||||||
|
kinds: [kinds.Mutelist],
|
||||||
|
authors: [hexPubkey]
|
||||||
|
}
|
||||||
|
const muteListEvent = await ndkContext.fetchEventFromUserRelays(
|
||||||
|
filter,
|
||||||
|
hexPubkey,
|
||||||
|
UserRelaysType.Write
|
||||||
|
)
|
||||||
|
if (!muteListEvent) {
|
||||||
|
toast.error(`Couldn't get user's mute list event from relays`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = muteListEvent.tags
|
||||||
|
const unsignedEvent: UnsignedEvent = {
|
||||||
|
pubkey: muteListEvent.pubkey,
|
||||||
|
kind: kinds.Mutelist,
|
||||||
|
content: muteListEvent.content,
|
||||||
|
created_at: now(),
|
||||||
|
tags: tags.filter((item) => item[0] !== 'a' || item[1] !== aTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUpdated = await signAndPublish(
|
||||||
|
unsignedEvent,
|
||||||
|
ndkContext.ndk,
|
||||||
|
ndkContext.publish
|
||||||
|
)
|
||||||
|
if (!isUpdated) {
|
||||||
|
toast.error("Failed to update user's mute list")
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const handleAddNSFW = async () => {
|
||||||
|
const success = await addToCurationSet(
|
||||||
|
{
|
||||||
|
dTag: CurationSetIdentifiers.NSFW,
|
||||||
|
pubkey: hexPubkey,
|
||||||
|
ndkContext
|
||||||
|
},
|
||||||
|
aTag
|
||||||
|
)
|
||||||
|
log(true, LogType.Info, `ModAction - NSFW - Add - ${success}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const handleRemoveNSFW = async () => {
|
||||||
|
const success = await removeFromCurationSet(
|
||||||
|
{
|
||||||
|
dTag: CurationSetIdentifiers.NSFW,
|
||||||
|
pubkey: hexPubkey,
|
||||||
|
ndkContext
|
||||||
|
},
|
||||||
|
aTag
|
||||||
|
)
|
||||||
|
log(true, LogType.Info, `ModAction - Repost - Remove - ${success}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRepost = async () => {
|
||||||
|
const success = await addToCurationSet(
|
||||||
|
{
|
||||||
|
dTag: CurationSetIdentifiers.Repost,
|
||||||
|
pubkey: hexPubkey,
|
||||||
|
ndkContext
|
||||||
|
},
|
||||||
|
aTag
|
||||||
|
)
|
||||||
|
log(true, LogType.Info, `ModAction - NSFW - Add - ${success}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveRepost = async () => {
|
||||||
|
const success = await removeFromCurationSet(
|
||||||
|
{
|
||||||
|
dTag: CurationSetIdentifiers.Repost,
|
||||||
|
pubkey: hexPubkey,
|
||||||
|
ndkContext
|
||||||
|
},
|
||||||
|
aTag
|
||||||
|
)
|
||||||
|
log(true, LogType.Info, `ModAction - Repost - Remove - ${success}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestData = (await request.json()) as {
|
||||||
|
intent: 'nsfw' | 'block' | 'repost'
|
||||||
|
value: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (requestData.intent) {
|
||||||
|
case 'block':
|
||||||
|
await (requestData.value ? handleBlock() : handleUnblock())
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'repost':
|
||||||
|
await (requestData.value ? handleRepost() : handleRemoveRepost())
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'nsfw':
|
||||||
|
if (!isAdmin) {
|
||||||
|
log(true, LogType.Error, 'Unable to update NSFW list. No permission')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
await (requestData.value ? handleAddNSFW() : handleRemoveNSFW())
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
log(true, LogType.Error, 'Missing intent for mod action')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
@ -1,23 +1,21 @@
|
|||||||
import { NDKFilter, NDKKind } from '@nostr-dev-kit/ndk'
|
|
||||||
import Link from '@tiptap/extension-link'
|
import Link from '@tiptap/extension-link'
|
||||||
import { EditorContent, useEditor } from '@tiptap/react'
|
import { EditorContent, useEditor } from '@tiptap/react'
|
||||||
import StarterKit from '@tiptap/starter-kit'
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
import FsLightbox from 'fslightbox-react'
|
import FsLightbox from 'fslightbox-react'
|
||||||
import { kinds, nip19, UnsignedEvent } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { Link as ReactRouterLink, useParams } from 'react-router-dom'
|
import {
|
||||||
|
Link as ReactRouterLink,
|
||||||
|
useLoaderData,
|
||||||
|
useNavigation,
|
||||||
|
useParams,
|
||||||
|
useSubmit
|
||||||
|
} from 'react-router-dom'
|
||||||
import { toast } from 'react-toastify'
|
import { toast } from 'react-toastify'
|
||||||
import { BlogCard } from '../../components/BlogCard'
|
import { BlogCard } from '../../components/BlogCard'
|
||||||
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
|
||||||
import { ProfileSection } from '../../components/ProfileSection'
|
import { ProfileSection } from '../../components/ProfileSection'
|
||||||
import {
|
import { useAppSelector, useBodyScrollDisable } from '../../hooks'
|
||||||
useAppSelector,
|
import { getGamePageRoute } from '../../routes'
|
||||||
useBodyScrollDisable,
|
|
||||||
useDidMount,
|
|
||||||
useNDKContext,
|
|
||||||
useNSFWList
|
|
||||||
} from '../../hooks'
|
|
||||||
import { getGamePageRoute, getModsEditPageRoute } from '../../routes'
|
|
||||||
import '../../styles/comments.css'
|
import '../../styles/comments.css'
|
||||||
import '../../styles/downloads.css'
|
import '../../styles/downloads.css'
|
||||||
import '../../styles/innerPage.css'
|
import '../../styles/innerPage.css'
|
||||||
@ -28,77 +26,46 @@ import '../../styles/styles.css'
|
|||||||
import '../../styles/tabs.css'
|
import '../../styles/tabs.css'
|
||||||
import '../../styles/tags.css'
|
import '../../styles/tags.css'
|
||||||
import '../../styles/write.css'
|
import '../../styles/write.css'
|
||||||
import {
|
import { DownloadUrl, ModPageLoaderResult } from '../../types'
|
||||||
BlogCardDetails,
|
|
||||||
DownloadUrl,
|
|
||||||
ModDetails,
|
|
||||||
UserRelaysType
|
|
||||||
} from '../../types'
|
|
||||||
import {
|
import {
|
||||||
copyTextToClipboard,
|
copyTextToClipboard,
|
||||||
downloadFile,
|
downloadFile,
|
||||||
extractModData,
|
getFilenameFromUrl
|
||||||
getFilenameFromUrl,
|
|
||||||
log,
|
|
||||||
LogType,
|
|
||||||
now,
|
|
||||||
npubToHex,
|
|
||||||
sendDMUsingRandomKey,
|
|
||||||
signAndPublish
|
|
||||||
} from '../../utils'
|
} from '../../utils'
|
||||||
import { Comments } from '../../components/comment'
|
import { Comments } from '../../components/comment'
|
||||||
import { CheckboxField } from 'components/Inputs'
|
|
||||||
import { PublishDetails } from 'components/Internal/PublishDetails'
|
import { PublishDetails } from 'components/Internal/PublishDetails'
|
||||||
import { Interactions } from 'components/Internal/Interactions'
|
import { Interactions } from 'components/Internal/Interactions'
|
||||||
import { extractBlogCardDetails } from 'utils/blog'
|
import { ReportPopup } from 'components/ReportPopup'
|
||||||
|
import { Spinner } from 'components/Spinner'
|
||||||
|
import { RouterLoadingSpinner } from 'components/LoadingSpinner'
|
||||||
|
|
||||||
|
const MOD_REPORT_REASONS = [
|
||||||
|
{ label: 'Actually CP', key: 'actuallyCP' },
|
||||||
|
{ label: 'Spam', key: 'spam' },
|
||||||
|
{ label: 'Scam', key: 'scam' },
|
||||||
|
{ label: 'Not a game mod', key: 'notAGameMod' },
|
||||||
|
{ label: 'Stolen game mod', key: 'stolenGameMod' },
|
||||||
|
{ label: `Repost of a game mod`, key: 'repost' },
|
||||||
|
{ label: `Wasn't tagged NSFW`, key: 'wasntTaggedNSFW' },
|
||||||
|
{ label: 'Other reason', key: 'otherReason' }
|
||||||
|
]
|
||||||
|
|
||||||
export const ModPage = () => {
|
export const ModPage = () => {
|
||||||
|
const { mod } = useLoaderData() as ModPageLoaderResult
|
||||||
|
|
||||||
|
// We can get author right away from naddr, no need to wait for mod data
|
||||||
const { naddr } = useParams()
|
const { naddr } = useParams()
|
||||||
const { fetchEvent } = useNDKContext()
|
let author = mod?.author
|
||||||
const [mod, setMod] = useState<ModDetails>()
|
if (naddr && !author) {
|
||||||
const [isFetching, setIsFetching] = useState(true)
|
try {
|
||||||
const [commentCount, setCommentCount] = useState(0)
|
|
||||||
|
|
||||||
// Make sure to mark non-nsfw mods as NSFW if found in nsfwList
|
|
||||||
const nsfwList = useNSFWList()
|
|
||||||
const isMissingNsfwTag =
|
|
||||||
!mod?.nsfw && mod?.aTag && nsfwList && nsfwList.includes(mod.aTag)
|
|
||||||
const modData = isMissingNsfwTag
|
|
||||||
? ({ ...mod, nsfw: true } as ModDetails)
|
|
||||||
: mod
|
|
||||||
|
|
||||||
useDidMount(async () => {
|
|
||||||
if (naddr) {
|
|
||||||
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
|
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
|
||||||
const { identifier, kind, pubkey } = decoded.data
|
author = decoded.data.pubkey
|
||||||
|
} catch (error) {
|
||||||
const filter: NDKFilter = {
|
// Silently ignore - we will get author eventually from mods
|
||||||
'#a': [identifier],
|
}
|
||||||
authors: [pubkey],
|
|
||||||
kinds: [kind]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchEvent(filter)
|
const [commentCount, setCommentCount] = useState(0)
|
||||||
.then((event) => {
|
|
||||||
if (event) {
|
|
||||||
const extracted = extractModData(event)
|
|
||||||
setMod(extracted)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
log(
|
|
||||||
true,
|
|
||||||
LogType.Error,
|
|
||||||
'An error occurred in fetching mod details from relays',
|
|
||||||
err
|
|
||||||
)
|
|
||||||
toast.error('An error occurred in fetching mod details from relays')
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setIsFetching(false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const oldDownloadListRef = useRef<HTMLDivElement>(null)
|
const oldDownloadListRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
@ -119,51 +86,47 @@ export const ModPage = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isFetching)
|
|
||||||
return <LoadingSpinner desc='Fetching mod details from relays' />
|
|
||||||
|
|
||||||
if (!modData) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<RouterLoadingSpinner />
|
||||||
<div className='InnerBodyMain'>
|
<div className='InnerBodyMain'>
|
||||||
<div className='ContainerMain'>
|
<div className='ContainerMain'>
|
||||||
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
|
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
|
||||||
<div className='IBMSMSplitMain'>
|
<div className='IBMSMSplitMain'>
|
||||||
<div className='IBMSMSplitMainBigSide'>
|
<div className='IBMSMSplitMainBigSide'>
|
||||||
|
{mod ? (
|
||||||
|
<>
|
||||||
<div className='IBMSMSplitMainBigSideSec'>
|
<div className='IBMSMSplitMainBigSideSec'>
|
||||||
<Game
|
<Game />
|
||||||
naddr={naddr!}
|
|
||||||
game={modData.game}
|
|
||||||
author={modData.author}
|
|
||||||
aTag={modData.aTag}
|
|
||||||
/>
|
|
||||||
<Body
|
<Body
|
||||||
featuredImageUrl={modData.featuredImageUrl}
|
featuredImageUrl={mod.featuredImageUrl}
|
||||||
title={modData.title}
|
title={mod.title}
|
||||||
body={modData.body}
|
body={mod.body}
|
||||||
screenshotsUrls={modData.screenshotsUrls}
|
screenshotsUrls={mod.screenshotsUrls}
|
||||||
tags={modData.tags}
|
tags={mod.tags}
|
||||||
nsfw={modData.nsfw}
|
nsfw={mod.nsfw}
|
||||||
/>
|
/>
|
||||||
<Interactions
|
<Interactions
|
||||||
addressable={modData}
|
addressable={mod}
|
||||||
commentCount={commentCount}
|
commentCount={commentCount}
|
||||||
/>
|
/>
|
||||||
<PublishDetails
|
<PublishDetails
|
||||||
published_at={modData.published_at}
|
published_at={mod.published_at}
|
||||||
edited_at={modData.edited_at}
|
edited_at={mod.edited_at}
|
||||||
site={modData.rTag}
|
site={mod.rTag}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className='IBMSMSplitMainBigSideSec'>
|
<div className='IBMSMSplitMainBigSideSec'>
|
||||||
<div className='IBMSMSMBSSDownloadsWrapper'>
|
<div className='IBMSMSMBSSDownloadsWrapper'>
|
||||||
<h4 className='IBMSMSMBSSDownloadsTitle'>Mod Download</h4>
|
<h4 className='IBMSMSMBSSDownloadsTitle'>
|
||||||
{modData.downloadUrls.length > 0 && (
|
Mod Download
|
||||||
|
</h4>
|
||||||
|
{mod.downloadUrls.length > 0 && (
|
||||||
<div className='IBMSMSMBSSDownloadsPrime'>
|
<div className='IBMSMSMBSSDownloadsPrime'>
|
||||||
<Download {...modData.downloadUrls[0]} />
|
<Download {...mod.downloadUrls[0]} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{modData.downloadUrls.length > 1 && (
|
{mod.downloadUrls.length > 1 && (
|
||||||
<>
|
<>
|
||||||
<div className='IBMSMSMBSSDownloadsActions'>
|
<div className='IBMSMSMBSSDownloadsActions'>
|
||||||
<button
|
<button
|
||||||
@ -179,9 +142,13 @@ export const ModPage = () => {
|
|||||||
ref={oldDownloadListRef}
|
ref={oldDownloadListRef}
|
||||||
id='oldDownloadList'
|
id='oldDownloadList'
|
||||||
className='IBMSMSMBSSDownloads'
|
className='IBMSMSMBSSDownloads'
|
||||||
style={{ padding: 0, height: '0px', border: 'unset' }}
|
style={{
|
||||||
|
padding: 0,
|
||||||
|
height: '0px',
|
||||||
|
border: 'unset'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{modData.downloadUrls
|
{mod.downloadUrls
|
||||||
.slice(1)
|
.slice(1)
|
||||||
.map((download, index) => (
|
.map((download, index) => (
|
||||||
<Download
|
<Download
|
||||||
@ -197,326 +164,90 @@ export const ModPage = () => {
|
|||||||
<DisplayModAuthorBlogs />
|
<DisplayModAuthorBlogs />
|
||||||
<div className='IBMSMSplitMainBigSideSec'>
|
<div className='IBMSMSplitMainBigSideSec'>
|
||||||
<Comments
|
<Comments
|
||||||
addressable={modData}
|
addressable={mod}
|
||||||
setCommentCount={setCommentCount}
|
setCommentCount={setCommentCount}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Spinner />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ProfileSection pubkey={modData.author} />
|
{typeof author !== 'undefined' && (
|
||||||
|
<ProfileSection pubkey={author} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type GameProps = {
|
const Game = () => {
|
||||||
naddr: string
|
const navigation = useNavigation()
|
||||||
game: string
|
const { mod, isAddedToNSFW, isBlocked, isRepost } =
|
||||||
author: string
|
useLoaderData() as ModPageLoaderResult
|
||||||
aTag: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const Game = ({ naddr, game, author, aTag }: GameProps) => {
|
|
||||||
const { ndk, fetchEventFromUserRelays, publish } = useNDKContext()
|
|
||||||
|
|
||||||
const userState = useAppSelector((state) => state.user)
|
const userState = useAppSelector((state) => state.user)
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [showReportPopUp, setShowReportPopUp] = useState<number | undefined>()
|
||||||
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
|
|
||||||
const [showReportPopUp, setShowReportPopUp] = useState(false)
|
|
||||||
const [isBlocked, setIsBlocked] = useState(false)
|
|
||||||
const [isAddedToNSFW, setIsAddedToNSFW] = useState(false)
|
|
||||||
|
|
||||||
useBodyScrollDisable(showReportPopUp)
|
useBodyScrollDisable(!!showReportPopUp)
|
||||||
|
|
||||||
useEffect(() => {
|
const submit = useSubmit()
|
||||||
if (userState.auth && userState.user?.pubkey) {
|
const handleBlock = () => {
|
||||||
const pubkey = userState.user.pubkey as string
|
if (navigation.state === 'idle') {
|
||||||
|
submit(
|
||||||
const muteListFilter: NDKFilter = {
|
{
|
||||||
kinds: [NDKKind.MuteList],
|
intent: 'block',
|
||||||
authors: [pubkey]
|
value: !isBlocked
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'post',
|
||||||
|
encType: 'application/json'
|
||||||
}
|
}
|
||||||
|
|
||||||
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: NDKFilter = {
|
|
||||||
kinds: [NDKKind.ArticleCurationSet],
|
|
||||||
authors: [pubkey],
|
|
||||||
'#d': ['nsfw']
|
|
||||||
}
|
|
||||||
|
|
||||||
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, fetchEventFromUserRelays])
|
|
||||||
|
|
||||||
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: NDKFilter = {
|
|
||||||
kinds: [NDKKind.MuteList],
|
|
||||||
authors: [hexPubkey]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch the mute list event from the relays. This returns the event containing the user's mute list.
|
|
||||||
const muteListEvent = await 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: NDKKind.MuteList,
|
|
||||||
content: muteListEvent.content,
|
|
||||||
created_at: now(),
|
|
||||||
tags: [...tags]
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
unsignedEvent = {
|
|
||||||
pubkey: hexPubkey,
|
|
||||||
kind: NDKKind.MuteList,
|
|
||||||
content: '',
|
|
||||||
created_at: now(),
|
|
||||||
tags: [['a', aTag]]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoadingSpinnerDesc('Updating mute list event')
|
const handleNSFW = () => {
|
||||||
|
if (navigation.state === 'idle') {
|
||||||
const isUpdated = await signAndPublish(unsignedEvent, ndk, publish)
|
submit(
|
||||||
if (isUpdated) {
|
{
|
||||||
setIsBlocked(true)
|
intent: 'nsfw',
|
||||||
|
value: !isAddedToNSFW
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'post',
|
||||||
|
encType: 'application/json'
|
||||||
}
|
}
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUnblock = async () => {
|
|
||||||
const pubkey = userState.user?.pubkey as string
|
|
||||||
|
|
||||||
const filter: NDKFilter = {
|
|
||||||
kinds: [NDKKind.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 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 handleRepost = () => {
|
||||||
|
if (navigation.state === 'idle') {
|
||||||
const unsignedEvent: UnsignedEvent = {
|
submit(
|
||||||
pubkey: muteListEvent.pubkey,
|
{
|
||||||
kind: NDKKind.MuteList,
|
intent: 'repost',
|
||||||
content: muteListEvent.content,
|
value: !isRepost
|
||||||
created_at: now(),
|
},
|
||||||
tags: tags.filter((item) => item[0] !== 'a' || item[1] !== aTag)
|
{
|
||||||
|
method: 'post',
|
||||||
|
encType: 'application/json'
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoadingSpinnerDesc('Updating mute list event')
|
|
||||||
const isUpdated = await signAndPublish(unsignedEvent, ndk, publish)
|
|
||||||
if (isUpdated) {
|
|
||||||
setIsBlocked(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBlockNSFW = async () => {
|
|
||||||
const pubkey = userState.user?.pubkey as string | undefined
|
|
||||||
|
|
||||||
if (!pubkey) return
|
|
||||||
|
|
||||||
const filter: NDKFilter = {
|
|
||||||
kinds: [NDKKind.ArticleCurationSet],
|
|
||||||
authors: [pubkey],
|
|
||||||
'#d': ['nsfw']
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true)
|
|
||||||
setLoadingSpinnerDesc('Finding NSFW list')
|
|
||||||
|
|
||||||
const nsfwListEvent = await 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: NDKKind.ArticleCurationSet,
|
|
||||||
content: nsfwListEvent.content,
|
|
||||||
created_at: now(),
|
|
||||||
tags: [...tags]
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
unsignedEvent = {
|
|
||||||
pubkey: pubkey,
|
|
||||||
kind: NDKKind.ArticleCurationSet,
|
|
||||||
content: '',
|
|
||||||
created_at: now(),
|
|
||||||
tags: [
|
|
||||||
['a', aTag],
|
|
||||||
['d', 'nsfw']
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoadingSpinnerDesc('Updating nsfw list event')
|
|
||||||
|
|
||||||
const isUpdated = await signAndPublish(unsignedEvent, ndk, publish)
|
|
||||||
if (isUpdated) {
|
|
||||||
setIsAddedToNSFW(true)
|
|
||||||
}
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUnblockNSFW = async () => {
|
|
||||||
const pubkey = userState.user?.pubkey as string
|
|
||||||
|
|
||||||
const filter: NDKFilter = {
|
|
||||||
kinds: [NDKKind.ArticleCurationSet],
|
|
||||||
authors: [pubkey],
|
|
||||||
'#d': ['nsfw']
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true)
|
|
||||||
setLoadingSpinnerDesc('Finding NSFW list')
|
|
||||||
|
|
||||||
const nsfwListEvent = await 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: NDKKind.ArticleCurationSet,
|
|
||||||
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, ndk, publish)
|
|
||||||
if (isUpdated) {
|
|
||||||
setIsAddedToNSFW(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAdmin =
|
const isAdmin =
|
||||||
userState.user?.npub &&
|
userState.user?.npub &&
|
||||||
userState.user.npub === import.meta.env.VITE_REPORTING_NPUB
|
userState.user.npub === import.meta.env.VITE_REPORTING_NPUB
|
||||||
|
|
||||||
|
const game = mod?.game || ''
|
||||||
const gameRoute = getGamePageRoute(game)
|
const gameRoute = getGamePageRoute(game)
|
||||||
const editRoute = getModsEditPageRoute(naddr)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
|
|
||||||
<div className='IBMSMSMBSSModFor'>
|
<div className='IBMSMSMBSSModFor'>
|
||||||
<p className='IBMSMSMBSSModForPara'>
|
<p className='IBMSMSMBSSModForPara'>
|
||||||
Mod for:
|
Mod for:
|
||||||
@ -547,10 +278,10 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div className={`dropdown-menu dropdown-menu-end dropdownMainMenu`}>
|
<div className={`dropdown-menu dropdown-menu-end dropdownMainMenu`}>
|
||||||
{userState.auth && userState.user?.pubkey === author && (
|
{userState.auth && userState.user?.pubkey === mod?.author && (
|
||||||
<ReactRouterLink
|
<ReactRouterLink
|
||||||
className='dropdown-item dropdownMainMenuItem'
|
className='dropdown-item dropdownMainMenuItem'
|
||||||
to={editRoute}
|
to={'edit'}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
@ -602,7 +333,9 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => {
|
|||||||
<a
|
<a
|
||||||
className='dropdown-item dropdownMainMenuItem'
|
className='dropdown-item dropdownMainMenuItem'
|
||||||
id='reportPost'
|
id='reportPost'
|
||||||
onClick={() => setShowReportPopUp(true)}
|
onClick={() => {
|
||||||
|
setShowReportPopUp(Date.now())
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
@ -618,7 +351,7 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => {
|
|||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
className='dropdown-item dropdownMainMenuItem'
|
className='dropdown-item dropdownMainMenuItem'
|
||||||
onClick={isBlocked ? handleUnblock : handleBlock}
|
onClick={handleBlock}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
@ -633,9 +366,10 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => {
|
|||||||
{isBlocked ? 'Unblock' : 'Block'} Post
|
{isBlocked ? 'Unblock' : 'Block'} Post
|
||||||
</a>
|
</a>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
|
<>
|
||||||
<a
|
<a
|
||||||
className='dropdown-item dropdownMainMenuItem'
|
className='dropdown-item dropdownMainMenuItem'
|
||||||
onClick={isAddedToNSFW ? handleUnblockNSFW : handleBlockNSFW}
|
onClick={handleNSFW}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
@ -649,213 +383,34 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => {
|
|||||||
</svg>
|
</svg>
|
||||||
{isAddedToNSFW ? 'Un-mark' : 'Mark'} as NSFW
|
{isAddedToNSFW ? 'Un-mark' : 'Mark'} as NSFW
|
||||||
</a>
|
</a>
|
||||||
)}
|
<a
|
||||||
</div>
|
className='dropdown-item dropdownMainMenuItem'
|
||||||
</div>
|
onClick={handleRepost}
|
||||||
</div>
|
>
|
||||||
{showReportPopUp && (
|
|
||||||
<ReportPopup
|
|
||||||
aTag={aTag}
|
|
||||||
handleClose={() => setShowReportPopUp(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReportPopupProps = {
|
|
||||||
aTag: string
|
|
||||||
handleClose: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const MOD_REPORT_REASONS = [
|
|
||||||
{ label: 'Actually CP', key: 'actuallyCP' },
|
|
||||||
{ label: 'Spam', key: 'spam' },
|
|
||||||
{ label: 'Scam', key: 'scam' },
|
|
||||||
{ label: 'Not a game mod', key: 'notAGameMod' },
|
|
||||||
{ label: 'Stolen game mod', key: 'stolenGameMod' },
|
|
||||||
{ label: `Wasn't tagged NSFW`, key: 'wasntTaggedNSFW' },
|
|
||||||
{ label: 'Other reason', key: 'otherReason' }
|
|
||||||
]
|
|
||||||
|
|
||||||
const ReportPopup = ({ aTag, handleClose }: ReportPopupProps) => {
|
|
||||||
const { ndk, fetchEventFromUserRelays, publish } = useNDKContext()
|
|
||||||
const userState = useAppSelector((state) => state.user)
|
|
||||||
const [selectedOptions, setSelectedOptions] = useState(
|
|
||||||
MOD_REPORT_REASONS.reduce((acc: { [key: string]: boolean }, cur) => {
|
|
||||||
acc[cur.key] = false
|
|
||||||
return acc
|
|
||||||
}, {})
|
|
||||||
)
|
|
||||||
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 reportingNpub = import.meta.env.VITE_REPORTING_NPUB
|
|
||||||
const reportingPubkey = npubToHex(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: NDKFilter = {
|
|
||||||
kinds: [NDKKind.MuteList],
|
|
||||||
authors: [hexPubkey]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch the mute list event from the relays. This returns the event containing the user's mute list.
|
|
||||||
const muteListEvent = await 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: NDKKind.MuteList,
|
|
||||||
content: muteListEvent.content,
|
|
||||||
created_at: now(),
|
|
||||||
tags: [...tags]
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
unsignedEvent = {
|
|
||||||
pubkey: hexPubkey,
|
|
||||||
kind: NDKKind.MuteList,
|
|
||||||
content: '',
|
|
||||||
created_at: now(),
|
|
||||||
tags: [['a', aTag]]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoadingSpinnerDesc('Updating mute list event')
|
|
||||||
const isUpdated = await signAndPublish(unsignedEvent, ndk, publish)
|
|
||||||
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!,
|
|
||||||
ndk,
|
|
||||||
publish
|
|
||||||
)
|
|
||||||
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
|
<svg
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
viewBox='-96 0 512 512'
|
viewBox='-32 0 512 512'
|
||||||
width='1em'
|
width='1em'
|
||||||
height='1em'
|
height='1em'
|
||||||
fill='currentColor'
|
fill='currentColor'
|
||||||
style={{ zIndex: 1 }}
|
className='IBMSMSMSSS_Author_Top_Icon'
|
||||||
>
|
>
|
||||||
<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>
|
<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>
|
</svg>
|
||||||
|
{isRepost ? 'Un-mark' : 'Mark'} as Repost
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='pUMCB_Zaps'>
|
</div>
|
||||||
<div className='pUMCB_ZapsInside'>
|
{!!showReportPopUp && (
|
||||||
<div className='inputLabelWrapperMain'>
|
<ReportPopup
|
||||||
<label
|
openedAt={showReportPopUp}
|
||||||
className='form-label labelMain'
|
reasons={MOD_REPORT_REASONS}
|
||||||
style={{ fontWeight: 'bold' }}
|
handleClose={() => setShowReportPopUp(undefined)}
|
||||||
>
|
|
||||||
Why are you reporting this?
|
|
||||||
</label>
|
|
||||||
{MOD_REPORT_REASONS.map((r) => (
|
|
||||||
<CheckboxField
|
|
||||||
key={r.key}
|
|
||||||
label={r.label}
|
|
||||||
name={r.key}
|
|
||||||
isChecked={selectedOptions[r.key]}
|
|
||||||
handleChange={() => handleCheckboxChange(r.key)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
)}
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className='btn btnMain pUMCB_Report'
|
|
||||||
type='button'
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
onClick={handleSubmit}
|
|
||||||
>
|
|
||||||
Submit Report
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -934,8 +489,8 @@ const Body = ({
|
|||||||
<img
|
<img
|
||||||
className='IBMSMSMBSSShotsImg'
|
className='IBMSMSMBSSShotsImg'
|
||||||
src={url}
|
src={url}
|
||||||
alt={`ScreenShot-${index}`}
|
alt=''
|
||||||
key={url}
|
key={`ScreenShot-${index}`}
|
||||||
onClick={() => openLightBoxOnSlide(index + 1)}
|
onClick={() => openLightBoxOnSlide(index + 1)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@ -1223,43 +778,16 @@ const Download = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DisplayModAuthorBlogs = () => {
|
const DisplayModAuthorBlogs = () => {
|
||||||
const { naddr } = useParams()
|
const { latest } = useLoaderData() as ModPageLoaderResult
|
||||||
const [blogs, setBlogs] = useState<Partial<BlogCardDetails>[]>()
|
|
||||||
const { fetchEvents } = useNDKContext()
|
|
||||||
|
|
||||||
useDidMount(() => {
|
if (!latest?.length) return null
|
||||||
const fetchBlogs = async () => {
|
|
||||||
try {
|
|
||||||
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
|
|
||||||
const { pubkey } = decoded.data
|
|
||||||
const latestBlogPosts = await fetchEvents({
|
|
||||||
authors: [pubkey],
|
|
||||||
kinds: [kinds.LongFormArticle],
|
|
||||||
limit: 3
|
|
||||||
})
|
|
||||||
setBlogs(latestBlogPosts.map(extractBlogCardDetails))
|
|
||||||
} catch (error) {
|
|
||||||
log(
|
|
||||||
true,
|
|
||||||
LogType.Error,
|
|
||||||
'An error occurred in fetching blog details from relays',
|
|
||||||
error
|
|
||||||
)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchBlogs()
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!blogs?.length) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='IBMSMSplitMainBigSideSec'>
|
<div className='IBMSMSplitMainBigSideSec'>
|
||||||
<div className='IBMSMSMBSSPostsWrapper'>
|
<div className='IBMSMSMBSSPostsWrapper'>
|
||||||
<h4 className='IBMSMSMBSSPostsTitle'>Creator's Blog Posts</h4>
|
<h4 className='IBMSMSMBSSPostsTitle'>Creator's Blog Posts</h4>
|
||||||
<div className='IBMSMList IBMSMListAlt'>
|
<div className='IBMSMList IBMSMListAlt'>
|
||||||
{blogs?.map((b) => (
|
{latest?.map((b) => (
|
||||||
<BlogCard key={b.id} {...b} />
|
<BlogCard key={b.id} {...b} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
228
src/pages/mod/loader.ts
Normal file
228
src/pages/mod/loader.ts
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
import { NDKFilter } from '@nostr-dev-kit/ndk'
|
||||||
|
import { PROFILE_BLOG_FILTER_LIMIT } from '../../constants'
|
||||||
|
import { NDKContextType } from 'contexts/NDKContext'
|
||||||
|
import { kinds, nip19 } from 'nostr-tools'
|
||||||
|
import { LoaderFunctionArgs, redirect } from 'react-router-dom'
|
||||||
|
import { appRoutes } from 'routes'
|
||||||
|
import { store } from 'store'
|
||||||
|
import {
|
||||||
|
FilterOptions,
|
||||||
|
ModeratedFilter,
|
||||||
|
ModPageLoaderResult,
|
||||||
|
NSFWFilter
|
||||||
|
} from 'types'
|
||||||
|
import {
|
||||||
|
DEFAULT_FILTER_OPTIONS,
|
||||||
|
extractBlogCardDetails,
|
||||||
|
extractModData,
|
||||||
|
getLocalStorageItem,
|
||||||
|
log,
|
||||||
|
LogType
|
||||||
|
} from 'utils'
|
||||||
|
|
||||||
|
export const modRouteLoader =
|
||||||
|
(ndkContext: NDKContextType) =>
|
||||||
|
async ({ params }: LoaderFunctionArgs) => {
|
||||||
|
const { naddr } = params
|
||||||
|
if (!naddr) {
|
||||||
|
log(true, LogType.Error, 'Required naddr.')
|
||||||
|
return redirect(appRoutes.blogs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode from naddr
|
||||||
|
let pubkey: string | undefined
|
||||||
|
let identifier: string | undefined
|
||||||
|
let kind: number | undefined
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
|
||||||
|
identifier = decoded.data.identifier
|
||||||
|
kind = decoded.data.kind
|
||||||
|
pubkey = decoded.data.pubkey
|
||||||
|
} catch (error) {
|
||||||
|
log(true, LogType.Error, `Failed to decode naddr: ${naddr}`, error)
|
||||||
|
throw new Error('Failed to fetch the blog. The address might be wrong')
|
||||||
|
}
|
||||||
|
|
||||||
|
const userState = store.getState().user
|
||||||
|
const loggedInUserPubkey = userState?.user?.pubkey as string | undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Set up the filters
|
||||||
|
// Main mod content
|
||||||
|
const modFilter: NDKFilter = {
|
||||||
|
'#a': [identifier],
|
||||||
|
authors: [pubkey],
|
||||||
|
kinds: [kind]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the blog filter options for latest blogs
|
||||||
|
const filterOptions = JSON.parse(
|
||||||
|
getLocalStorageItem('filter-blog', DEFAULT_FILTER_OPTIONS)
|
||||||
|
) as FilterOptions
|
||||||
|
|
||||||
|
// Fetch more in case the current blog is included in the latest and filters remove some
|
||||||
|
const latestFilter: NDKFilter = {
|
||||||
|
authors: [pubkey],
|
||||||
|
kinds: [kinds.LongFormArticle],
|
||||||
|
limit: PROFILE_BLOG_FILTER_LIMIT
|
||||||
|
}
|
||||||
|
// Add source filter
|
||||||
|
if (filterOptions.source === window.location.host) {
|
||||||
|
latestFilter['#r'] = [filterOptions.source]
|
||||||
|
}
|
||||||
|
// Filter by NSFW tag
|
||||||
|
// NSFWFilter.Only_NSFW -> fetch with content-warning label
|
||||||
|
// NSFWFilter.Show_NSFW -> filter not needed
|
||||||
|
// NSFWFilter.Hide_NSFW -> up the limit and filter after fetch
|
||||||
|
if (filterOptions.nsfw === NSFWFilter.Only_NSFW) {
|
||||||
|
latestFilter['#L'] = ['content-warning']
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parallel fetch blog event, latest events, mute, and nsfw lists
|
||||||
|
const settled = await Promise.allSettled([
|
||||||
|
ndkContext.fetchEvent(modFilter),
|
||||||
|
ndkContext.fetchEvents(latestFilter),
|
||||||
|
ndkContext.getMuteLists(loggedInUserPubkey), // Pass pubkey for logged-in users
|
||||||
|
ndkContext.getNSFWList()
|
||||||
|
])
|
||||||
|
|
||||||
|
const result: ModPageLoaderResult = {
|
||||||
|
mod: undefined,
|
||||||
|
latest: [],
|
||||||
|
isAddedToNSFW: false,
|
||||||
|
isBlocked: false,
|
||||||
|
isRepost: false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the mod event result
|
||||||
|
const fetchEventResult = settled[0]
|
||||||
|
if (fetchEventResult.status === 'fulfilled' && fetchEventResult.value) {
|
||||||
|
// Extract the mod data from the event
|
||||||
|
result.mod = extractModData(fetchEventResult.value)
|
||||||
|
} else if (fetchEventResult.status === 'rejected') {
|
||||||
|
log(
|
||||||
|
true,
|
||||||
|
LogType.Error,
|
||||||
|
'Unable to fetch the blog event.',
|
||||||
|
fetchEventResult.reason
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throw an error if we are missing the main mod result
|
||||||
|
// Handle it with the react-router's errorComponent
|
||||||
|
if (!result.mod) {
|
||||||
|
throw new Error('We are unable to find the mod on the relays')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the lateast blog events
|
||||||
|
const fetchEventsResult = settled[1]
|
||||||
|
if (fetchEventsResult.status === 'fulfilled' && fetchEventsResult.value) {
|
||||||
|
// Extract the blog card details from the events
|
||||||
|
result.latest = fetchEventsResult.value.map(extractBlogCardDetails)
|
||||||
|
} else if (fetchEventsResult.status === 'rejected') {
|
||||||
|
log(
|
||||||
|
true,
|
||||||
|
LogType.Error,
|
||||||
|
'Unable to fetch the latest blog events.',
|
||||||
|
fetchEventsResult.reason
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const muteLists = settled[2]
|
||||||
|
if (muteLists.status === 'fulfilled' && muteLists.value) {
|
||||||
|
if (muteLists && muteLists.value) {
|
||||||
|
if (result.mod && result.mod.aTag) {
|
||||||
|
if (
|
||||||
|
muteLists.value.admin.replaceableEvents.includes(
|
||||||
|
result.mod.aTag
|
||||||
|
) ||
|
||||||
|
muteLists.value.user.replaceableEvents.includes(result.mod.aTag)
|
||||||
|
) {
|
||||||
|
result.isBlocked = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Moderate the latest
|
||||||
|
const isAdmin =
|
||||||
|
userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
|
||||||
|
const isOwner =
|
||||||
|
userState.user?.pubkey && userState.user.pubkey === pubkey
|
||||||
|
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"
|
||||||
|
// Allow "Unmoderated Fully" when author visits own profile
|
||||||
|
if (!((isAdmin || isOwner) && isUnmoderatedFully)) {
|
||||||
|
result.latest = result.latest.filter(
|
||||||
|
(b) =>
|
||||||
|
!muteLists.value.admin.authors.includes(b.author!) &&
|
||||||
|
!muteLists.value.admin.replaceableEvents.includes(b.aTag!)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterOptions.moderated === ModeratedFilter.Moderated) {
|
||||||
|
result.latest = result.latest.filter(
|
||||||
|
(b) =>
|
||||||
|
!muteLists.value.user.authors.includes(b.author!) &&
|
||||||
|
!muteLists.value.user.replaceableEvents.includes(b.aTag!)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (muteLists.status === 'rejected') {
|
||||||
|
log(true, LogType.Error, 'Issue fetching mute list', muteLists.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nsfwList = settled[3]
|
||||||
|
if (nsfwList.status === 'fulfilled' && nsfwList.value) {
|
||||||
|
// Check if the mod is marked as NSFW
|
||||||
|
// Mark it as NSFW only if it's missing the tag
|
||||||
|
if (result.mod) {
|
||||||
|
const isMissingNsfwTag =
|
||||||
|
!result.mod.nsfw &&
|
||||||
|
result.mod.aTag &&
|
||||||
|
nsfwList.value.includes(result.mod.aTag)
|
||||||
|
|
||||||
|
if (isMissingNsfwTag) {
|
||||||
|
result.mod.nsfw = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.mod.aTag && nsfwList.value.includes(result.mod.aTag)) {
|
||||||
|
result.isAddedToNSFW = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check the latest blogs too
|
||||||
|
result.latest = result.latest.map((b) => {
|
||||||
|
// Add nsfw tag if it's missing
|
||||||
|
const isMissingNsfwTag =
|
||||||
|
!b.nsfw && b.aTag && nsfwList.value.includes(b.aTag)
|
||||||
|
|
||||||
|
if (isMissingNsfwTag) {
|
||||||
|
b.nsfw = true
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
})
|
||||||
|
} else if (nsfwList.status === 'rejected') {
|
||||||
|
log(true, LogType.Error, 'Issue fetching nsfw list', nsfwList.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter latest, sort and take only three
|
||||||
|
result.latest = result.latest
|
||||||
|
.filter(
|
||||||
|
// Filter out the NSFW if selected
|
||||||
|
(b) => !(b.nsfw && filterOptions.nsfw === NSFWFilter.Hide_NSFW)
|
||||||
|
)
|
||||||
|
.sort((a, b) =>
|
||||||
|
a.published_at && b.published_at ? b.published_at - a.published_at : 0
|
||||||
|
)
|
||||||
|
.slice(0, 3)
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
let message = 'An error occurred in fetching mod details from relays'
|
||||||
|
log(true, LogType.Error, message, error)
|
||||||
|
if (error instanceof Error) {
|
||||||
|
message = error.message
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -5,12 +5,14 @@ import { SearchPage } from '../pages/search'
|
|||||||
import { AboutPage } from '../pages/about'
|
import { AboutPage } from '../pages/about'
|
||||||
import { GamesPage } from '../pages/games'
|
import { GamesPage } from '../pages/games'
|
||||||
import { HomePage } from '../pages/home'
|
import { HomePage } from '../pages/home'
|
||||||
import { ModPage } from '../pages/mod'
|
|
||||||
import { ModsPage } from '../pages/mods'
|
import { ModsPage } from '../pages/mods'
|
||||||
|
import { ModPage } from '../pages/mod'
|
||||||
|
import { modRouteLoader } from '../pages/mod/loader'
|
||||||
|
import { modRouteAction } from '../pages/mod/action'
|
||||||
|
import { SubmitModPage } from '../pages/submitMod'
|
||||||
import { ProfilePage } from '../pages/profile'
|
import { ProfilePage } from '../pages/profile'
|
||||||
import { profileRouteLoader } from 'pages/profile/loader'
|
import { profileRouteLoader } from 'pages/profile/loader'
|
||||||
import { SettingsPage } from '../pages/settings'
|
import { SettingsPage } from '../pages/settings'
|
||||||
import { SubmitModPage } from '../pages/submitMod'
|
|
||||||
import { GamePage } from '../pages/game'
|
import { GamePage } from '../pages/game'
|
||||||
import { NotFoundPage } from '../pages/404'
|
import { NotFoundPage } from '../pages/404'
|
||||||
import { FeedLayout } from '../layout/feed'
|
import { FeedLayout } from '../layout/feed'
|
||||||
@ -18,12 +20,12 @@ import { FeedPage } from '../pages/feed'
|
|||||||
import { NotificationsPage } from '../pages/notifications'
|
import { NotificationsPage } from '../pages/notifications'
|
||||||
import { WritePage } from '../pages/write'
|
import { WritePage } from '../pages/write'
|
||||||
import { writeRouteAction } from '../pages/write/action'
|
import { writeRouteAction } from '../pages/write/action'
|
||||||
import { BlogsPage } from 'pages/blogs'
|
import { BlogsPage } from '../pages/blogs'
|
||||||
import { blogsRouteLoader } from 'pages/blogs/loader'
|
import { blogsRouteLoader } from '../pages/blogs/loader'
|
||||||
import { BlogPage } from 'pages/blog'
|
import { BlogPage } from '../pages/blog'
|
||||||
import { blogRouteLoader } from 'pages/blog/loader'
|
import { blogRouteLoader } from '../pages/blog/loader'
|
||||||
import { blogRouteAction } from 'pages/blog/action'
|
import { blogRouteAction } from '../pages/blog/action'
|
||||||
import { blogReportRouteAction } from 'pages/blog/reportAction'
|
import { reportRouteAction } from '../actions/report'
|
||||||
|
|
||||||
export const appRoutes = {
|
export const appRoutes = {
|
||||||
index: '/',
|
index: '/',
|
||||||
@ -32,6 +34,7 @@ export const appRoutes = {
|
|||||||
game: '/game/:name',
|
game: '/game/:name',
|
||||||
mods: '/mods',
|
mods: '/mods',
|
||||||
mod: '/mod/:naddr',
|
mod: '/mod/:naddr',
|
||||||
|
modReport_actionOnly: '/mod/:naddr/report',
|
||||||
about: '/about',
|
about: '/about',
|
||||||
blogs: '/blog',
|
blogs: '/blog',
|
||||||
blog: '/blog/:naddr',
|
blog: '/blog/:naddr',
|
||||||
@ -88,7 +91,14 @@ export const routerWithNdkContext = (context: NDKContextType) =>
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: appRoutes.mod,
|
path: appRoutes.mod,
|
||||||
element: <ModPage />
|
element: <ModPage />,
|
||||||
|
loader: modRouteLoader(context),
|
||||||
|
action: modRouteAction(context),
|
||||||
|
errorElement: <NotFoundPage title={'Something went wrong.'} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: appRoutes.modReport_actionOnly,
|
||||||
|
action: reportRouteAction(context)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: appRoutes.about,
|
path: appRoutes.about,
|
||||||
@ -115,7 +125,7 @@ export const routerWithNdkContext = (context: NDKContextType) =>
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: appRoutes.blogReport_actionOnly,
|
path: appRoutes.blogReport_actionOnly,
|
||||||
action: blogReportRouteAction(context)
|
action: reportRouteAction(context)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: appRoutes.submitMod,
|
path: appRoutes.submitMod,
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
|
import { BlogDetails } from 'types'
|
||||||
|
|
||||||
export enum CommentEventStatus {
|
export enum CommentEventStatus {
|
||||||
Publishing = 'Publishing comment...',
|
Publishing = 'Publishing comment...',
|
||||||
@ -26,6 +27,7 @@ export interface ModFormState {
|
|||||||
featuredImageUrl: string
|
featuredImageUrl: string
|
||||||
summary: string
|
summary: string
|
||||||
nsfw: boolean
|
nsfw: boolean
|
||||||
|
repost: boolean
|
||||||
screenshotsUrls: string[]
|
screenshotsUrls: string[]
|
||||||
tags: string
|
tags: string
|
||||||
downloadUrls: DownloadUrl[]
|
downloadUrls: DownloadUrl[]
|
||||||
@ -52,3 +54,11 @@ export interface MuteLists {
|
|||||||
authors: string[]
|
authors: string[]
|
||||||
replaceableEvents: string[]
|
replaceableEvents: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ModPageLoaderResult {
|
||||||
|
mod: ModDetails | undefined
|
||||||
|
latest: Partial<BlogDetails>[]
|
||||||
|
isAddedToNSFW: boolean
|
||||||
|
isBlocked: boolean
|
||||||
|
isRepost: boolean
|
||||||
|
}
|
||||||
|
4
src/types/report.ts
Normal file
4
src/types/report.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export interface ReportReason {
|
||||||
|
label: string
|
||||||
|
key: string
|
||||||
|
}
|
159
src/utils/curationSets.ts
Normal file
159
src/utils/curationSets.ts
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
import { NDKFilter } from '@nostr-dev-kit/ndk'
|
||||||
|
import { NDKContextType } from 'contexts/NDKContext'
|
||||||
|
import { UnsignedEvent, kinds } from 'nostr-tools'
|
||||||
|
import { toast } from 'react-toastify'
|
||||||
|
import { UserRelaysType } from 'types'
|
||||||
|
import { now, signAndPublish } from './nostr'
|
||||||
|
|
||||||
|
interface CurationSetArgs {
|
||||||
|
dTag: CurationSetIdentifiers
|
||||||
|
pubkey: string
|
||||||
|
ndkContext: NDKContextType
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used for dTag when updating
|
||||||
|
*/
|
||||||
|
export enum CurationSetIdentifiers {
|
||||||
|
NSFW = 'nsfw',
|
||||||
|
Repost = 'repost'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the article curation set (kind: 30004) for the user (pubkey)
|
||||||
|
* with initial article (optional)
|
||||||
|
* @see https://github.com/nostr-protocol/nips/blob/master/51.md#sets
|
||||||
|
*/
|
||||||
|
export async function createCurationSet(
|
||||||
|
{ dTag, pubkey, ndkContext }: CurationSetArgs,
|
||||||
|
initialArticle?: string
|
||||||
|
) {
|
||||||
|
const curationSetName = `${dTag} - Degmods's curation set`
|
||||||
|
const tags = [
|
||||||
|
['d', dTag],
|
||||||
|
['title', curationSetName]
|
||||||
|
]
|
||||||
|
if (initialArticle) {
|
||||||
|
tags.push(['a', initialArticle])
|
||||||
|
}
|
||||||
|
const unsignedEvent: UnsignedEvent = {
|
||||||
|
pubkey: pubkey,
|
||||||
|
kind: kinds.Curationsets,
|
||||||
|
content: '',
|
||||||
|
created_at: now(),
|
||||||
|
tags
|
||||||
|
}
|
||||||
|
const isUpdated = await signAndPublish(
|
||||||
|
unsignedEvent,
|
||||||
|
ndkContext.ndk,
|
||||||
|
ndkContext.publish
|
||||||
|
)
|
||||||
|
if (!isUpdated) {
|
||||||
|
toast.error(`Failed to create user's ${dTag} curation set`)
|
||||||
|
}
|
||||||
|
return isUpdated
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurationSet({
|
||||||
|
dTag,
|
||||||
|
pubkey,
|
||||||
|
ndkContext
|
||||||
|
}: CurationSetArgs) {
|
||||||
|
const filter: NDKFilter = {
|
||||||
|
kinds: [kinds.Curationsets],
|
||||||
|
authors: [pubkey],
|
||||||
|
'#d': [dTag]
|
||||||
|
}
|
||||||
|
|
||||||
|
const nsfwListEvent = await ndkContext.fetchEventFromUserRelays(
|
||||||
|
filter,
|
||||||
|
pubkey,
|
||||||
|
UserRelaysType.Write
|
||||||
|
)
|
||||||
|
|
||||||
|
return nsfwListEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the article curation set (kind: 30004) for the user (pubkey)
|
||||||
|
* Add the aTag to the dTag list
|
||||||
|
* @see https://github.com/nostr-protocol/nips/blob/master/51.md#sets
|
||||||
|
*/
|
||||||
|
export async function addToCurationSet(
|
||||||
|
curationSetArgs: CurationSetArgs,
|
||||||
|
aTag: string
|
||||||
|
) {
|
||||||
|
const curationSetEvent = await getCurationSet(curationSetArgs)
|
||||||
|
|
||||||
|
let isUpdated = false
|
||||||
|
if (curationSetEvent) {
|
||||||
|
const { dTag, pubkey, ndkContext } = curationSetArgs
|
||||||
|
const tags = curationSetEvent.tags
|
||||||
|
const alreadyExists =
|
||||||
|
tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== -1
|
||||||
|
|
||||||
|
if (alreadyExists) {
|
||||||
|
toast.warn(`Already in user's ${dTag} list`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
tags.push(['a', aTag])
|
||||||
|
|
||||||
|
const unsignedEvent = {
|
||||||
|
pubkey: pubkey,
|
||||||
|
kind: kinds.Curationsets,
|
||||||
|
content: curationSetEvent.content,
|
||||||
|
created_at: now(),
|
||||||
|
tags: [...tags]
|
||||||
|
}
|
||||||
|
|
||||||
|
isUpdated = await signAndPublish(
|
||||||
|
unsignedEvent,
|
||||||
|
ndkContext.ndk,
|
||||||
|
ndkContext.publish
|
||||||
|
)
|
||||||
|
if (!isUpdated) {
|
||||||
|
toast.error(`Failed to update user's ${dTag} list`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
isUpdated = await createCurationSet(curationSetArgs, aTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
return isUpdated
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the article curation set (kind: 30004) for the user (pubkey)
|
||||||
|
* Remove the aTag from the dTag list
|
||||||
|
*/
|
||||||
|
export async function removeFromCurationSet(
|
||||||
|
curationSetArgs: CurationSetArgs,
|
||||||
|
aTag: string
|
||||||
|
) {
|
||||||
|
const curationSetEvent = await getCurationSet(curationSetArgs)
|
||||||
|
const { dTag, pubkey, ndkContext } = curationSetArgs
|
||||||
|
|
||||||
|
if (!curationSetEvent) {
|
||||||
|
toast.error(`Couldn't get ${dTag} list event from relays`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const tags = curationSetEvent.tags
|
||||||
|
|
||||||
|
const unsignedEvent: UnsignedEvent = {
|
||||||
|
pubkey: pubkey,
|
||||||
|
kind: kinds.Curationsets,
|
||||||
|
content: curationSetEvent.content,
|
||||||
|
created_at: now(),
|
||||||
|
tags: tags.filter((item) => item[0] !== 'a' || item[1] !== aTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUpdated = await signAndPublish(
|
||||||
|
unsignedEvent,
|
||||||
|
ndkContext.ndk,
|
||||||
|
ndkContext.publish
|
||||||
|
)
|
||||||
|
if (!isUpdated) {
|
||||||
|
toast.error(`Failed to update user's ${dTag} list`)
|
||||||
|
}
|
||||||
|
return isUpdated
|
||||||
|
}
|
@ -6,3 +6,4 @@ export * from './zap'
|
|||||||
export * from './localStorage'
|
export * from './localStorage'
|
||||||
export * from './consts'
|
export * from './consts'
|
||||||
export * from './blog'
|
export * from './blog'
|
||||||
|
export * from './curationSets'
|
||||||
|
@ -39,6 +39,7 @@ export const extractModData = (event: Event | NDKEvent): ModDetails => {
|
|||||||
featuredImageUrl: getFirstTagValue('featuredImageUrl'),
|
featuredImageUrl: getFirstTagValue('featuredImageUrl'),
|
||||||
summary: getFirstTagValue('summary'),
|
summary: getFirstTagValue('summary'),
|
||||||
nsfw: getFirstTagValue('nsfw') === 'true',
|
nsfw: getFirstTagValue('nsfw') === 'true',
|
||||||
|
repost: getFirstTagValue('repost') === 'true',
|
||||||
screenshotsUrls: getTagValue(event, 'screenshotsUrls') || [],
|
screenshotsUrls: getTagValue(event, 'screenshotsUrls') || [],
|
||||||
tags: getTagValue(event, 'tags') || [],
|
tags: getTagValue(event, 'tags') || [],
|
||||||
downloadUrls: (getTagValue(event, 'downloadUrls') || []).map((item) =>
|
downloadUrls: (getTagValue(event, 'downloadUrls') || []).map((item) =>
|
||||||
@ -118,6 +119,7 @@ export const initializeFormState = (
|
|||||||
featuredImageUrl: existingModData?.featuredImageUrl || '',
|
featuredImageUrl: existingModData?.featuredImageUrl || '',
|
||||||
summary: existingModData?.summary || '',
|
summary: existingModData?.summary || '',
|
||||||
nsfw: existingModData?.nsfw || false,
|
nsfw: existingModData?.nsfw || false,
|
||||||
|
repost: existingModData?.repost || false,
|
||||||
screenshotsUrls: existingModData?.screenshotsUrls || [''],
|
screenshotsUrls: existingModData?.screenshotsUrls || [''],
|
||||||
tags: existingModData?.tags.join(',') || '',
|
tags: existingModData?.tags.join(',') || '',
|
||||||
downloadUrls: existingModData?.downloadUrls || [
|
downloadUrls: existingModData?.downloadUrls || [
|
||||||
|
Loading…
x
Reference in New Issue
Block a user