chore(git): merge pull request #162 from 107-143-refactoring-mod-and-repost into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 48s

Reviewed-on: #162
This commit is contained in:
enes 2024-11-28 16:33:14 +00:00
commit f5d03263e7
38 changed files with 1820 additions and 1088 deletions

View File

@ -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 return false
} }
} else {
log(
true,
LogType.Error,
`Failed to send a ${title.toLowerCase()} report: VITE_REPORTING_NPUB missing`
)
return false
} }
} }

View File

@ -0,0 +1,165 @@
import { useAppSelector, useLocalStorage } from 'hooks'
import React from 'react'
import {
FilterOptions,
ModeratedFilter,
NSFWFilter,
SortBy,
WOTFilterOptions
} from 'types'
import { DEFAULT_FILTER_OPTIONS } from 'utils'
import { Dropdown } from './Dropdown'
import { Option } from './Option'
import { Filter } from '.'
type Props = {
author?: string | undefined
filterKey?: string | undefined
}
export const BlogsFilter = React.memo(
({ author, filterKey = 'filter-blog' }: Props) => {
const userState = useAppSelector((state) => state.user)
const [filterOptions, setFilterOptions] = useLocalStorage<FilterOptions>(
filterKey,
DEFAULT_FILTER_OPTIONS
)
return (
<Filter>
{/* sort filter options */}
<Dropdown label={filterOptions.sort}>
{Object.values(SortBy).map((item, index) => (
<div
key={`sortByItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
sort: item
}))
}
>
{item}
</div>
))}
</Dropdown>
{/* moderation filter options */}
<Dropdown label={filterOptions.moderated}>
{Object.values(ModeratedFilter).map((item) => {
if (item === ModeratedFilter.Unmoderated_Fully) {
const isAdmin =
userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
const isOwnProfile =
author && userState.auth && userState.user?.pubkey === author
if (!(isAdmin || isOwnProfile)) return null
}
return (
<Option
key={`sort-${item}`}
onClick={() =>
setFilterOptions((prev) => ({
...prev,
moderated: item
}))
}
>
{item}
</Option>
)
})}
</Dropdown>
{/* wot filter options */}
<Dropdown label={<>Trust: {filterOptions.wot}</>}>
{Object.values(WOTFilterOptions).map((item, index) => {
// when user is not logged in
if (item === WOTFilterOptions.Site_And_Mine && !userState.auth) {
return null
}
// when logged in user not admin
if (
item === WOTFilterOptions.None ||
item === WOTFilterOptions.Mine_Only ||
item === WOTFilterOptions.Exclude
) {
const isWoTNpub =
userState.user?.npub === import.meta.env.VITE_SITE_WOT_NPUB
const isOwnProfile =
author && userState.auth && userState.user?.pubkey === author
if (!(isWoTNpub || isOwnProfile)) return null
}
return (
<Option
key={`wotFilterOption-${index}`}
onClick={() =>
setFilterOptions((prev) => ({
...prev,
wot: item
}))
}
>
{item}
</Option>
)
})}
</Dropdown>
{/* nsfw filter options */}
<Dropdown label={filterOptions.nsfw}>
{Object.values(NSFWFilter).map((item, index) => (
<Option
key={`nsfwFilterItem-${index}`}
onClick={() =>
setFilterOptions((prev) => ({
...prev,
nsfw: item
}))
}
>
{item}
</Option>
))}
</Dropdown>
{/* source filter options */}
<Dropdown
label={
filterOptions.source === window.location.host
? `Show From: ${filterOptions.source}`
: 'Show All'
}
>
<Option
onClick={() =>
setFilterOptions((prev) => ({
...prev,
source: window.location.host
}))
}
>
Show From: {window.location.host}
</Option>
<Option
onClick={() =>
setFilterOptions((prev) => ({
...prev,
source: 'Show All'
}))
}
>
Show All
</Option>
</Dropdown>
</Filter>
)
}
)

View File

@ -0,0 +1,25 @@
import { PropsWithChildren } from 'react'
interface DropdownProps {
label: React.ReactNode
}
export const Dropdown = ({
label,
children
}: PropsWithChildren<DropdownProps>) => {
return (
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
{label}
</button>
<div className='dropdown-menu dropdownMainMenu'>{children}</div>
</div>
</div>
)
}

View File

@ -0,0 +1,182 @@
import { useAppSelector, useLocalStorage } from 'hooks'
import React from 'react'
import {
FilterOptions,
SortBy,
ModeratedFilter,
WOTFilterOptions,
NSFWFilter,
RepostFilter
} from 'types'
import { DEFAULT_FILTER_OPTIONS } from 'utils'
import { Filter } from '.'
import { Dropdown } from './Dropdown'
import { Option } from './Option'
type Props = {
author?: string | undefined
filterKey?: string | undefined
}
export const ModFilter = React.memo(
({ author, filterKey = 'filter' }: Props) => {
const userState = useAppSelector((state) => state.user)
const [filterOptions, setFilterOptions] = useLocalStorage<FilterOptions>(
filterKey,
DEFAULT_FILTER_OPTIONS
)
return (
<Filter>
{/* sort filter options */}
<Dropdown label={filterOptions.sort}>
{Object.values(SortBy).map((item, index) => (
<Option
key={`sortByItem-${index}`}
onClick={() =>
setFilterOptions((prev) => ({
...prev,
sort: item
}))
}
>
{item}
</Option>
))}
</Dropdown>
{/* moderation filter options */}
<Dropdown label={filterOptions.moderated}>
{Object.values(ModeratedFilter).map((item, index) => {
if (item === ModeratedFilter.Unmoderated_Fully) {
const isAdmin =
userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
const isOwnProfile =
author && userState.auth && userState.user?.pubkey === author
if (!(isAdmin || isOwnProfile)) return null
}
return (
<Option
key={`moderatedFilterItem-${index}`}
onClick={() =>
setFilterOptions((prev) => ({
...prev,
moderated: item
}))
}
>
{item}
</Option>
)
})}
</Dropdown>
{/* wot filter options */}
<Dropdown label={<>Trust: {filterOptions.wot}</>}>
{Object.values(WOTFilterOptions).map((item, index) => {
// when user is not logged in
if (item === WOTFilterOptions.Site_And_Mine && !userState.auth) {
return null
}
// when logged in user not admin
if (
item === WOTFilterOptions.None ||
item === WOTFilterOptions.Mine_Only ||
item === WOTFilterOptions.Exclude
) {
const isWoTNpub =
userState.user?.npub === import.meta.env.VITE_SITE_WOT_NPUB
const isOwnProfile =
author && userState.auth && userState.user?.pubkey === author
if (!(isWoTNpub || isOwnProfile)) return null
}
return (
<Option
key={`wotFilterOption-${index}`}
onClick={() =>
setFilterOptions((prev) => ({
...prev,
wot: item
}))
}
>
{item}
</Option>
)
})}
</Dropdown>
{/* nsfw filter options */}
<Dropdown label={filterOptions.nsfw}>
{Object.values(NSFWFilter).map((item, index) => (
<Option
key={`nsfwFilterItem-${index}`}
onClick={() =>
setFilterOptions((prev) => ({
...prev,
nsfw: item
}))
}
>
{item}
</Option>
))}
</Dropdown>
{/* repost filter options */}
<Dropdown label={filterOptions.repost}>
{Object.values(RepostFilter).map((item, index) => (
<Option
key={`repostFilterItem-${index}`}
onClick={() =>
setFilterOptions((prev) => ({
...prev,
repost: item
}))
}
>
{item}
</Option>
))}
</Dropdown>
{/* source filter options */}
<Dropdown
label={
filterOptions.source === window.location.host
? `Show From: ${filterOptions.source}`
: 'Show All'
}
>
<Option
onClick={() =>
setFilterOptions((prev) => ({
...prev,
source: window.location.host
}))
}
>
Show From: {window.location.host}
</Option>
<Option
onClick={() =>
setFilterOptions((prev) => ({
...prev,
source: 'Show All'
}))
}
>
Show All
</Option>
</Dropdown>
</Filter>
)
}
)

View File

@ -0,0 +1,16 @@
import { PropsWithChildren } from 'react'
interface OptionProps {
onClick: React.MouseEventHandler<HTMLDivElement>
}
export const Option = ({
onClick,
children
}: PropsWithChildren<OptionProps>) => {
return (
<div className='dropdown-item dropdownMainMenuItem' onClick={onClick}>
{children}
</div>
)
}

View File

@ -0,0 +1,9 @@
import { PropsWithChildren } from 'react'
export const Filter = ({ children }: PropsWithChildren) => {
return (
<div className='IBMSecMain'>
<div className='FiltersMain'>{children}</div>
</div>
)
}

View File

@ -7,7 +7,7 @@ import '../styles/styles.css'
import '../styles/tiptap.scss' import '../styles/tiptap.scss'
interface InputFieldProps { interface InputFieldProps {
label: string label: string | React.ReactElement
description?: string description?: string
type?: 'text' | 'textarea' | 'richtext' type?: 'text' | 'textarea' | 'richtext'
placeholder: string placeholder: string

View File

@ -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}...`} />
}

View File

@ -57,6 +57,11 @@ export const ModCard = React.memo((props: ModDetails) => {
<p>NSFW</p> <p>NSFW</p>
</div> </div>
)} )}
{props.repost && (
<div className='IBMSMSMBSSTagsTag IBMSMSMBSSTagsTagRepost IBMSMSMBSSTagsTagRepostCard'>
<p>REPOST</p>
</div>
)}
</div> </div>
<div className='cMMBody'> <div className='cMMBody'>
<h3 className='cMMBodyTitle'>{props.title}</h3> <h3 className='cMMBodyTitle'>{props.title}</h3>

View File

@ -29,6 +29,7 @@ import {
import { CheckboxField, InputError, InputField } from './Inputs' import { CheckboxField, InputError, InputField } from './Inputs'
import { LoadingSpinner } from './LoadingSpinner' import { LoadingSpinner } from './LoadingSpinner'
import { NDKEvent } from '@nostr-dev-kit/ndk' import { NDKEvent } from '@nostr-dev-kit/ndk'
import { OriginalAuthor } from './OriginalAuthor'
interface FormErrors { interface FormErrors {
game?: string game?: string
@ -40,6 +41,8 @@ interface FormErrors {
screenshotsUrls?: string[] screenshotsUrls?: string[]
tags?: string tags?: string
downloadUrls?: string[] downloadUrls?: string[]
author?: string
originalAuthor?: string
} }
interface GameOption { interface GameOption {
@ -198,36 +201,42 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
const aTag = const aTag =
formState.aTag || `${kinds.ClassifiedListing}:${hexPubkey}:${uuid}` formState.aTag || `${kinds.ClassifiedListing}:${hexPubkey}:${uuid}`
const tags = [
['d', uuid],
['a', aTag],
['r', formState.rTag],
['t', T_TAG_VALUE],
[
'published_at',
existingModData
? existingModData.published_at.toString()
: currentTimeStamp.toString()
],
['game', formState.game],
['title', formState.title],
['featuredImageUrl', formState.featuredImageUrl],
['summary', formState.summary],
['nsfw', formState.nsfw.toString()],
['repost', formState.repost.toString()],
['screenshotsUrls', ...formState.screenshotsUrls],
['tags', ...formState.tags.split(',')],
[
'downloadUrls',
...formState.downloadUrls.map((downloadUrl) =>
JSON.stringify(downloadUrl)
)
]
]
if (formState.repost && formState.originalAuthor) {
tags.push(['originalAuthor', formState.originalAuthor])
}
const unsignedEvent: UnsignedEvent = { const unsignedEvent: UnsignedEvent = {
kind: kinds.ClassifiedListing, kind: kinds.ClassifiedListing,
created_at: currentTimeStamp, created_at: currentTimeStamp,
pubkey: hexPubkey, pubkey: hexPubkey,
content: formState.body, content: formState.body,
tags: [ tags
['d', uuid],
['a', aTag],
['r', formState.rTag],
['t', T_TAG_VALUE],
[
'published_at',
existingModData
? existingModData.published_at.toString()
: currentTimeStamp.toString()
],
['game', formState.game],
['title', formState.title],
['featuredImageUrl', formState.featuredImageUrl],
['summary', formState.summary],
['nsfw', formState.nsfw.toString()],
['screenshotsUrls', ...formState.screenshotsUrls],
['tags', ...formState.tags.split(',')],
[
'downloadUrls',
...formState.downloadUrls.map((downloadUrl) =>
JSON.stringify(downloadUrl)
)
]
]
} }
const signedEvent = await window.nostr const signedEvent = await window.nostr
@ -318,6 +327,13 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
} }
} }
if (
formState.repost &&
(!formState.originalAuthor || formState.originalAuthor === '')
) {
errors.originalAuthor = 'Original author field can not be empty'
}
if (formState.tags === '') { if (formState.tags === '') {
errors.tags = 'Tags field can not be empty' errors.tags = 'Tags field can not be empty'
} }
@ -397,6 +413,31 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
handleChange={handleCheckboxChange} handleChange={handleCheckboxChange}
type='stylized' type='stylized'
/> />
<CheckboxField
label='This is a repost of a mod I did not create'
name='repost'
isChecked={formState.repost}
handleChange={handleCheckboxChange}
type='stylized'
/>
{formState.repost && (
<>
<InputField
label={
<span>
Created by:{' '}
{<OriginalAuthor value={formState.originalAuthor || ''} />}
</span>
}
type='text'
placeholder="Original author's name, npub or nprofile"
name='originalAuthor'
value={formState.originalAuthor || ''}
error={formErrors.originalAuthor || ''}
onChange={handleInputChange}
/>
</>
)}
<div className='inputLabelWrapperMain'> <div className='inputLabelWrapperMain'>
<div className='labelWrapperMain'> <div className='labelWrapperMain'>
<label className='form-label labelMain'>Screenshots URLs</label> <label className='form-label labelMain'>Screenshots URLs</label>

View File

@ -1,235 +0,0 @@
import { useAppSelector, useLocalStorage } from 'hooks'
import React from 'react'
import {
FilterOptions,
ModeratedFilter,
NSFWFilter,
SortBy,
WOTFilterOptions
} from 'types'
import { DEFAULT_FILTER_OPTIONS } from 'utils'
type Props = {
author?: string | undefined
filterKey?: string | undefined
}
export const ModFilter = React.memo(
({ author, filterKey = 'filter' }: Props) => {
const userState = useAppSelector((state) => state.user)
const [filterOptions, setFilterOptions] = useLocalStorage<FilterOptions>(
filterKey,
DEFAULT_FILTER_OPTIONS
)
return (
<div className='IBMSecMain'>
<div className='FiltersMain'>
{/* sort filter options */}
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
{filterOptions.sort}
</button>
<div className='dropdown-menu dropdownMainMenu'>
{Object.values(SortBy).map((item, index) => (
<div
key={`sortByItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
sort: item
}))
}
>
{item}
</div>
))}
</div>
</div>
</div>
{/* moderation filter options */}
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
{filterOptions.moderated}
</button>
<div className='dropdown-menu dropdownMainMenu'>
{Object.values(ModeratedFilter).map((item, index) => {
if (item === ModeratedFilter.Unmoderated_Fully) {
const isAdmin =
userState.user?.npub ===
import.meta.env.VITE_REPORTING_NPUB
const isOwnProfile =
author &&
userState.auth &&
userState.user?.pubkey === author
if (!(isAdmin || isOwnProfile)) return null
}
return (
<div
key={`moderatedFilterItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
moderated: item
}))
}
>
{item}
</div>
)
})}
</div>
</div>
</div>
{/* wot filter options */}
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
Trust: {filterOptions.wot}
</button>
<div className='dropdown-menu dropdownMainMenu'>
{Object.values(WOTFilterOptions).map((item, index) => {
// when user is not logged in
if (
item === WOTFilterOptions.Site_And_Mine &&
!userState.auth
) {
return null
}
// when logged in user not admin
if (
item === WOTFilterOptions.None ||
item === WOTFilterOptions.Mine_Only ||
item === WOTFilterOptions.Exclude
) {
const isWoTNpub =
userState.user?.npub ===
import.meta.env.VITE_SITE_WOT_NPUB
const isOwnProfile =
author &&
userState.auth &&
userState.user?.pubkey === author
if (!(isWoTNpub || isOwnProfile)) return null
}
return (
<div
key={`wotFilterOption-${index}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
wot: item
}))
}
>
{item}
</div>
)
})}
</div>
</div>
</div>
{/* nsfw filter options */}
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
{filterOptions.nsfw}
</button>
<div className='dropdown-menu dropdownMainMenu'>
{Object.values(NSFWFilter).map((item, index) => (
<div
key={`nsfwFilterItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
nsfw: item
}))
}
>
{item}
</div>
))}
</div>
</div>
</div>
{/* source filter options */}
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
{filterOptions.source === window.location.host
? `Show From: ${filterOptions.source}`
: 'Show All'}
</button>
<div className='dropdown-menu dropdownMainMenu'>
<div
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
source: window.location.host
}))
}
>
Show From: {window.location.host}
</div>
<div
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
source: 'Show All'
}))
}
>
Show All
</div>
</div>
</div>
</div>
</div>
</div>
)
}
)

View File

@ -0,0 +1,48 @@
import { nip19 } from 'nostr-tools'
import { appRoutes, getProfilePageRoute } from 'routes'
import { npubToHex } from 'utils'
import { ProfileLink } from './ProfileLink'
interface OriginalAuthorProps {
value: string
fallback?: boolean
}
export const OriginalAuthor = ({
value,
fallback = false
}: OriginalAuthorProps) => {
let profilePubkey
let displayName = '[name not set up]'
// Try to decode/encode depending on what we send to link
let profileRoute = appRoutes.home
try {
if (value.startsWith('nprofile1')) {
const decoded = nip19.decode(value as `nprofile1${string}`)
profileRoute = getProfilePageRoute(value)
profilePubkey = decoded?.data.pubkey
} else if (value.startsWith('npub1')) {
profilePubkey = npubToHex(value)
const nprofile = profilePubkey
? nip19.nprofileEncode({
pubkey: profilePubkey
})
: undefined
if (nprofile) {
profileRoute = getProfilePageRoute(nprofile)
}
} else {
displayName = value
}
} catch (error) {
console.error('Failed to create profile link:', error)
displayName = value
}
if (profileRoute && profilePubkey)
return <ProfileLink pubkey={profilePubkey} profileRoute={profileRoute} />
return fallback ? displayName : null
}

View File

@ -0,0 +1,15 @@
import { useProfile } from 'hooks/useProfile'
import { Link } from 'react-router-dom'
interface ProfileLinkProps {
pubkey: string
profileRoute: string
}
export const ProfileLink = ({ pubkey, profileRoute }: ProfileLinkProps) => {
const profile = useProfile(pubkey)
const displayName =
profile?.displayName || profile?.name || '[name not set up]'
return <Link to={profileRoute}>{displayName}</Link>
}

View File

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

View File

@ -0,0 +1,15 @@
import { useState } from 'react'
import { useNDKContext } from './useNDKContext'
import { useDidMount } from './useDidMount'
import { CurationSetIdentifiers, getReportingSet } from 'utils'
export const useCuratedSet = (type: CurationSetIdentifiers) => {
const ndkContext = useNDKContext()
const [curatedSet, setCuratedSet] = useState<string[]>([])
useDidMount(async () => {
setCuratedSet(await getReportingSet(type, ndkContext))
})
return curatedSet
}

View File

@ -6,6 +6,7 @@ import {
ModeratedFilter, ModeratedFilter,
MuteLists, MuteLists,
NSFWFilter, NSFWFilter,
RepostFilter,
SortBy, SortBy,
WOTFilterOptions WOTFilterOptions
} from 'types' } from 'types'
@ -22,6 +23,7 @@ export const useFilteredMods = (
admin: MuteLists admin: MuteLists
user: MuteLists user: MuteLists
}, },
repostList: string[],
author?: string | undefined author?: string | undefined
) => { ) => {
const { siteWot, siteWotLevel, userWot, userWotLevel } = useAppSelector( const { siteWot, siteWotLevel, userWot, userWotLevel } = useAppSelector(
@ -53,6 +55,30 @@ export const useFilteredMods = (
} }
} }
const repostFilter = (mods: ModDetails[]) => {
if (filterOptions.repost !== RepostFilter.Hide_Repost) {
// Add repost tag to mods included in repostList
mods = mods.map((mod) => {
return !mod.repost && repostList.includes(mod.aTag)
? { ...mod, repost: true }
: mod
})
}
// Determine the filtering logic based on the Repost filter option
switch (filterOptions.repost) {
case RepostFilter.Hide_Repost:
return mods.filter(
(mod) => !mod.repost && !repostList.includes(mod.aTag)
)
case RepostFilter.Show_Repost:
return mods
case RepostFilter.Only_Repost:
return mods.filter(
(mod) => mod.repost || repostList.includes(mod.aTag)
)
}
}
const wotFilter = (mods: ModDetails[]) => { const wotFilter = (mods: ModDetails[]) => {
// Determine the filtering logic based on the WOT filter option and user state // Determine the filtering logic based on the WOT filter option and user state
// when user is not logged in use Site_Only // when user is not logged in use Site_Only
@ -93,7 +119,7 @@ export const useFilteredMods = (
} }
let filtered = nsfwFilter(mods) let filtered = nsfwFilter(mods)
filtered = repostFilter(filtered)
filtered = wotFilter(filtered) filtered = wotFilter(filtered)
const isAdmin = userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB const isAdmin = userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
@ -135,10 +161,12 @@ export const useFilteredMods = (
filterOptions.moderated, filterOptions.moderated,
filterOptions.wot, filterOptions.wot,
filterOptions.nsfw, filterOptions.nsfw,
filterOptions.repost,
author, author,
mods, mods,
muteLists, muteLists,
nsfwList, nsfwList,
repostList,
siteWot, siteWot,
siteWotLevel, siteWotLevel,
userWot, userWot,

View File

@ -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)}
/>
)} )}
</> </>
)} )}

View File

@ -11,6 +11,9 @@ import '../../styles/styles.css'
import { PaginationWithPageNumbers } from 'components/Pagination' import { PaginationWithPageNumbers } from 'components/Pagination'
import { scrollIntoView } from 'utils' import { scrollIntoView } from 'utils'
import { LoadingSpinner } from 'components/LoadingSpinner' import { LoadingSpinner } from 'components/LoadingSpinner'
import { Filter } from 'components/Filters'
import { Dropdown } from 'components/Filters/Dropdown'
import { Option } from 'components/Filters/Option'
export const BlogsPage = () => { export const BlogsPage = () => {
const navigation = useNavigation() const navigation = useNavigation()
@ -126,66 +129,39 @@ export const BlogsPage = () => {
</div> </div>
</div> </div>
<div className='IBMSecMain'> <Filter>
<div className='FiltersMain'> <Dropdown label={filterOptions.sort}>
<div className='FiltersMainElement'> {Object.values(SortBy).map((item, index) => (
<div className='dropdown dropdownMain'> <Option
<button key={`sortByItem-${index}`}
className='btn dropdown-toggle btnMain btnMainDropdown' onClick={() =>
aria-expanded='false' setFilterOptions((prev) => ({
data-bs-toggle='dropdown' ...prev,
type='button' sort: item
> }))
{filterOptions.sort} }
</button> >
<div className='dropdown-menu dropdownMainMenu'> {item}
{Object.values(SortBy).map((item, index) => ( </Option>
<div ))}
key={`sortByItem-${index}`} </Dropdown>
className='dropdown-item dropdownMainMenuItem'
onClick={() => <Dropdown label={filterOptions.nsfw}>
setFilterOptions((prev) => ({ {Object.values(NSFWFilter).map((item, index) => (
...prev, <Option
sort: item key={`nsfwFilterItem-${index}`}
})) onClick={() =>
} setFilterOptions((prev) => ({
> ...prev,
{item} nsfw: item
</div> }))
))} }
</div> >
</div> {item}
</div> </Option>
<div className='FiltersMainElement'> ))}
<div className='dropdown dropdownMain'> </Dropdown>
<button </Filter>
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
{filterOptions.nsfw}
</button>
<div className='dropdown-menu dropdownMainMenu'>
{Object.values(NSFWFilter).map((item, index) => (
<div
key={`nsfwFilterItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
nsfw: item
}))
}
>
{item}
</div>
))}
</div>
</div>
</div>
</div>
</div>
<div className='IBMSecMain IBMSMListWrapper'> <div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMList'> <div className='IBMSMList'>

View File

@ -4,7 +4,7 @@ import {
NDKSubscriptionCacheUsage NDKSubscriptionCacheUsage
} from '@nostr-dev-kit/ndk' } from '@nostr-dev-kit/ndk'
import { ModCard } from 'components/ModCard' import { ModCard } from 'components/ModCard'
import { ModFilter } from 'components/ModsFilter' import { ModFilter } from 'components/Filters/ModsFilter'
import { PaginationWithPageNumbers } from 'components/Pagination' import { PaginationWithPageNumbers } from 'components/Pagination'
import { SearchInput } from 'components/SearchInput' import { SearchInput } from 'components/SearchInput'
import { MAX_MODS_PER_PAGE, T_TAG_VALUE } from 'constants.ts' import { MAX_MODS_PER_PAGE, T_TAG_VALUE } from 'constants.ts'
@ -20,11 +20,13 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { useParams, useSearchParams } from 'react-router-dom' import { useParams, useSearchParams } from 'react-router-dom'
import { FilterOptions, ModDetails } from 'types' import { FilterOptions, ModDetails } from 'types'
import { import {
CurationSetIdentifiers,
DEFAULT_FILTER_OPTIONS, DEFAULT_FILTER_OPTIONS,
extractModData, extractModData,
isModDataComplete, isModDataComplete,
scrollIntoView scrollIntoView
} from 'utils' } from 'utils'
import { useCuratedSet } from 'hooks/useCuratedSet'
export const GamePage = () => { export const GamePage = () => {
const scrollTargetRef = useRef<HTMLDivElement>(null) const scrollTargetRef = useRef<HTMLDivElement>(null)
@ -33,6 +35,7 @@ export const GamePage = () => {
const { ndk } = useNDKContext() const { ndk } = useNDKContext()
const muteLists = useMuteLists() const muteLists = useMuteLists()
const nsfwList = useNSFWList() const nsfwList = useNSFWList()
const repostList = useCuratedSet(CurationSetIdentifiers.Repost)
const [filterOptions] = useLocalStorage<FilterOptions>( const [filterOptions] = useLocalStorage<FilterOptions>(
'filter', 'filter',
@ -101,7 +104,8 @@ export const GamePage = () => {
userState, userState,
filterOptions, filterOptions,
nsfwList, nsfwList,
muteLists muteLists,
repostList
) )
// Pagination logic // Pagination logic

View File

@ -430,9 +430,7 @@ const DisplayLatestBlogs = () => {
return ( return (
<div className='IBMSecMain IBMSMListWrapper'> <div className='IBMSecMain IBMSMListWrapper'>
{navigation.state !== 'idle' && ( {navigation.state !== 'idle' && <LoadingSpinner desc={'Fetching...'} />}
<LoadingSpinner desc={'Fetching blog...'} />
)}
<div className='IBMSMTitleMain'> <div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>Blog Posts</h2> <h2 className='IBMSMTitleMainHeading'>Blog Posts</h2>
</div> </div>

233
src/pages/mod/action.ts Normal file
View 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
}

File diff suppressed because it is too large Load Diff

253
src/pages/mod/loader.ts Normal file
View File

@ -0,0 +1,253 @@
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 {
CurationSetIdentifiers,
DEFAULT_FILTER_OPTIONS,
extractBlogCardDetails,
extractModData,
getLocalStorageItem,
getReportingSet,
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
getReportingSet(CurationSetIdentifiers.NSFW, ndkContext),
getReportingSet(CurationSetIdentifiers.Repost, ndkContext)
])
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)
}
const repostList = settled[4]
if (repostList.status === 'fulfilled' && repostList.value) {
// Check if the mod is marked as Repost
// Mark it as Repost only if it's missing the tag
if (result.mod) {
const isMissingRepostTag =
!result.mod.repost &&
result.mod.aTag &&
repostList.value.includes(result.mod.aTag)
if (isMissingRepostTag) {
result.mod.repost = true
}
if (result.mod.aTag && repostList.value.includes(result.mod.aTag)) {
result.isRepost = true
}
}
} else if (repostList.status === 'rejected') {
log(true, LogType.Error, 'Issue fetching nsfw list', repostList.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)
}
}
}

View File

@ -1,29 +1,34 @@
import { ModFilter } from 'components/ModsFilter' import { ModFilter } from 'components/Filters/ModsFilter'
import { Pagination } from 'components/Pagination' import { Pagination } from 'components/Pagination'
import React, { useCallback, useEffect, useRef, useState } from 'react' import React, { useCallback, useEffect, useRef, useState } from 'react'
import { createSearchParams, useNavigate } from 'react-router-dom' import {
import { LoadingSpinner } from '../components/LoadingSpinner' createSearchParams,
import { ModCard } from '../components/ModCard' useLoaderData,
import { MOD_FILTER_LIMIT } from '../constants' useNavigate
} from 'react-router-dom'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { ModCard } from '../../components/ModCard'
import { MOD_FILTER_LIMIT } from '../../constants'
import { import {
useAppSelector, useAppSelector,
useFilteredMods, useFilteredMods,
useLocalStorage, useLocalStorage,
useMuteLists, useNDKContext
useNDKContext, } from '../../hooks'
useNSFWList import { appRoutes } from '../../routes'
} from '../hooks' import '../../styles/filters.css'
import { appRoutes } from '../routes' import '../../styles/pagination.css'
import '../styles/filters.css' import '../../styles/search.css'
import '../styles/pagination.css' import '../../styles/styles.css'
import '../styles/search.css' import { FilterOptions, ModDetails } from '../../types'
import '../styles/styles.css'
import { FilterOptions, ModDetails } from '../types'
import { DEFAULT_FILTER_OPTIONS, scrollIntoView } from 'utils' import { DEFAULT_FILTER_OPTIONS, scrollIntoView } from 'utils'
import { SearchInput } from 'components/SearchInput' import { SearchInput } from 'components/SearchInput'
import { ModsPageLoaderResult } from './loader'
export const ModsPage = () => { export const ModsPage = () => {
const scrollTargetRef = useRef<HTMLDivElement>(null) const scrollTargetRef = useRef<HTMLDivElement>(null)
const { repostList, muteLists, nsfwList } =
useLoaderData() as ModsPageLoaderResult
const { fetchMods } = useNDKContext() const { fetchMods } = useNDKContext()
const [isFetching, setIsFetching] = useState(false) const [isFetching, setIsFetching] = useState(false)
const [mods, setMods] = useState<ModDetails[]>([]) const [mods, setMods] = useState<ModDetails[]>([])
@ -32,8 +37,6 @@ export const ModsPage = () => {
'filter', 'filter',
DEFAULT_FILTER_OPTIONS DEFAULT_FILTER_OPTIONS
) )
const muteLists = useMuteLists()
const nsfwList = useNSFWList()
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
@ -94,7 +97,8 @@ export const ModsPage = () => {
userState, userState,
filterOptions, filterOptions,
nsfwList, nsfwList,
muteLists muteLists,
repostList
) )
return ( return (

77
src/pages/mods/loader.ts Normal file
View File

@ -0,0 +1,77 @@
import { NDKContextType } from 'contexts/NDKContext'
import { store } from 'store'
import { MuteLists } from 'types'
import { getReportingSet, CurationSetIdentifiers, log, LogType } from 'utils'
export interface ModsPageLoaderResult {
muteLists: {
admin: MuteLists
user: MuteLists
}
nsfwList: string[]
repostList: string[]
}
export const modsRouteLoader = (ndkContext: NDKContextType) => async () => {
// Empty result
const result: ModsPageLoaderResult = {
muteLists: {
admin: {
authors: [],
replaceableEvents: []
},
user: {
authors: [],
replaceableEvents: []
}
},
nsfwList: [],
repostList: []
}
// Get the current state
const userState = store.getState().user
// Check if current user is logged in
let userPubkey: string | undefined
if (userState.auth && userState.user?.pubkey) {
userPubkey = userState.user.pubkey as string
}
const settled = await Promise.allSettled([
ndkContext.getMuteLists(userPubkey),
getReportingSet(CurationSetIdentifiers.NSFW, ndkContext),
getReportingSet(CurationSetIdentifiers.Repost, ndkContext)
])
// Check the mutelist event result
const muteListResult = settled[0]
if (muteListResult.status === 'fulfilled' && muteListResult.value) {
result.muteLists = muteListResult.value
} else if (muteListResult.status === 'rejected') {
log(true, LogType.Error, 'Failed to fetch mutelist.', muteListResult.reason)
}
// Check the nsfwlist event result
const nsfwListResult = settled[1]
if (nsfwListResult.status === 'fulfilled' && nsfwListResult.value) {
result.nsfwList = nsfwListResult.value
} else if (nsfwListResult.status === 'rejected') {
log(true, LogType.Error, 'Failed to fetch nsfwlist.', nsfwListResult.reason)
}
// Check the repostlist event result
const repostListResult = settled[2]
if (repostListResult.status === 'fulfilled' && repostListResult.value) {
result.repostList = repostListResult.value
} else if (repostListResult.status === 'rejected') {
log(
true,
LogType.Error,
'Failed to fetch repost list.',
repostListResult.reason
)
}
return result
}

View File

@ -1,7 +1,7 @@
import { NDKFilter, NDKKind } from '@nostr-dev-kit/ndk' import { NDKFilter, NDKKind } from '@nostr-dev-kit/ndk'
import { LoadingSpinner } from 'components/LoadingSpinner' import { LoadingSpinner } from 'components/LoadingSpinner'
import { ModCard } from 'components/ModCard' import { ModCard } from 'components/ModCard'
import { ModFilter } from 'components/ModsFilter' import { ModFilter } from 'components/Filters/ModsFilter'
import { Pagination } from 'components/Pagination' import { Pagination } from 'components/Pagination'
import { ProfileSection } from 'components/ProfileSection' import { ProfileSection } from 'components/ProfileSection'
import { Tabs } from 'components/Tabs' import { Tabs } from 'components/Tabs'
@ -10,9 +10,7 @@ import {
useAppSelector, useAppSelector,
useFilteredMods, useFilteredMods,
useLocalStorage, useLocalStorage,
useMuteLists, useNDKContext
useNDKContext,
useNSFWList
} from 'hooks' } from 'hooks'
import { kinds, UnsignedEvent } from 'nostr-tools' import { kinds, UnsignedEvent } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -41,13 +39,17 @@ import {
import { CheckboxField } from 'components/Inputs' import { CheckboxField } from 'components/Inputs'
import { ProfilePageLoaderResult } from './loader' import { ProfilePageLoaderResult } from './loader'
import { BlogCard } from 'components/BlogCard' import { BlogCard } from 'components/BlogCard'
import { BlogsFilter } from 'components/Filters/BlogsFilter'
export const ProfilePage = () => { export const ProfilePage = () => {
const { const {
profilePubkey, profilePubkey,
profile, profile,
isBlocked: _isBlocked, isBlocked: _isBlocked,
isOwnProfile isOwnProfile,
repostList,
muteLists,
nsfwList
} = useLoaderData() as ProfilePageLoaderResult } = useLoaderData() as ProfilePageLoaderResult
const scrollTargetRef = useRef<HTMLDivElement>(null) const scrollTargetRef = useRef<HTMLDivElement>(null)
const { ndk, publish, fetchEventFromUserRelays, fetchMods } = useNDKContext() const { ndk, publish, fetchEventFromUserRelays, fetchMods } = useNDKContext()
@ -200,8 +202,6 @@ export const ProfilePage = () => {
const [filterOptions] = useLocalStorage<FilterOptions>(filterKey, { const [filterOptions] = useLocalStorage<FilterOptions>(filterKey, {
...DEFAULT_FILTER_OPTIONS ...DEFAULT_FILTER_OPTIONS
}) })
const muteLists = useMuteLists()
const nsfwList = useNSFWList()
const handleNext = useCallback(() => { const handleNext = useCallback(() => {
setIsLoading(true) setIsLoading(true)
@ -263,12 +263,14 @@ export const ProfilePage = () => {
break break
} }
}, [filterOptions.source, tab, fetchMods, profilePubkey]) }, [filterOptions.source, tab, fetchMods, profilePubkey])
const filteredModList = useFilteredMods( const filteredModList = useFilteredMods(
mods, mods,
userState, userState,
filterOptions, filterOptions,
nsfwList, nsfwList,
muteLists, muteLists,
repostList,
profilePubkey profilePubkey
) )
@ -824,7 +826,10 @@ const ProfileTabBlogs = () => {
<LoadingSpinner desc={'Loading...'} /> <LoadingSpinner desc={'Loading...'} />
)} )}
<ModFilter filterKey={'filter-blog'} author={profile?.pubkey as string} /> <BlogsFilter
filterKey={'filter-blog'}
author={profile?.pubkey as string}
/>
<div className='IBMSMList IBMSMListAlt'> <div className='IBMSMList IBMSMListAlt'>
{moderatedAndSortedBlogs.map((b) => ( {moderatedAndSortedBlogs.map((b) => (

View File

@ -4,7 +4,13 @@ import { LoaderFunctionArgs, redirect } from 'react-router-dom'
import { appRoutes, getProfilePageRoute } from 'routes' import { appRoutes, getProfilePageRoute } from 'routes'
import { store } from 'store' import { store } from 'store'
import { MuteLists, UserProfile } from 'types' import { MuteLists, UserProfile } from 'types'
import { log, LogType, npubToHex } from 'utils' import {
CurationSetIdentifiers,
getReportingSet,
log,
LogType,
npubToHex
} from 'utils'
export interface ProfilePageLoaderResult { export interface ProfilePageLoaderResult {
profilePubkey: string profilePubkey: string
@ -16,6 +22,7 @@ export interface ProfilePageLoaderResult {
user: MuteLists user: MuteLists
} }
nsfwList: string[] nsfwList: string[]
repostList: string[]
} }
export const profileRouteLoader = export const profileRouteLoader =
@ -87,7 +94,8 @@ export const profileRouteLoader =
replaceableEvents: [] replaceableEvents: []
} }
}, },
nsfwList: [] nsfwList: [],
repostList: []
} }
// Check if user the user is logged in // Check if user the user is logged in
@ -98,7 +106,8 @@ export const profileRouteLoader =
const settled = await Promise.allSettled([ const settled = await Promise.allSettled([
ndkContext.findMetadata(profilePubkey), ndkContext.findMetadata(profilePubkey),
ndkContext.getMuteLists(userPubkey), ndkContext.getMuteLists(userPubkey),
ndkContext.getNSFWList() getReportingSet(CurationSetIdentifiers.NSFW, ndkContext),
getReportingSet(CurationSetIdentifiers.Repost, ndkContext)
]) ])
// Check the profile event result // Check the profile event result
@ -114,7 +123,7 @@ export const profileRouteLoader =
) )
} }
// Check the profile event result // Check the mutelist event result
const muteListResult = settled[1] const muteListResult = settled[1]
if (muteListResult.status === 'fulfilled' && muteListResult.value) { if (muteListResult.status === 'fulfilled' && muteListResult.value) {
result.muteLists = muteListResult.value result.muteLists = muteListResult.value
@ -130,7 +139,7 @@ export const profileRouteLoader =
) )
} }
// Check the profile event result // Check the nsfwlist event result
const nsfwListResult = settled[2] const nsfwListResult = settled[2]
if (nsfwListResult.status === 'fulfilled' && nsfwListResult.value) { if (nsfwListResult.status === 'fulfilled' && nsfwListResult.value) {
result.nsfwList = nsfwListResult.value result.nsfwList = nsfwListResult.value
@ -138,10 +147,23 @@ export const profileRouteLoader =
log( log(
true, true,
LogType.Error, LogType.Error,
'Failed to fetch mutelist.', 'Failed to fetch nsfwlist.',
nsfwListResult.reason nsfwListResult.reason
) )
} }
// Check the repostlist event result
const repostListResult = settled[3]
if (repostListResult.status === 'fulfilled' && repostListResult.value) {
result.repostList = repostListResult.value
} else if (repostListResult.status === 'rejected') {
log(
true,
LogType.Error,
'Failed to fetch repost list.',
repostListResult.reason
)
}
return result return result
} }

View File

@ -10,7 +10,7 @@ import {
import { ErrorBoundary } from 'components/ErrorBoundary' import { ErrorBoundary } from 'components/ErrorBoundary'
import { GameCard } from 'components/GameCard' import { GameCard } from 'components/GameCard'
import { ModCard } from 'components/ModCard' import { ModCard } from 'components/ModCard'
import { ModFilter } from 'components/ModsFilter' import { ModFilter } from 'components/Filters/ModsFilter'
import { Pagination } from 'components/Pagination' import { Pagination } from 'components/Pagination'
import { Profile } from 'components/ProfileSection' import { Profile } from 'components/ProfileSection'
import { SearchInput } from 'components/SearchInput' import { SearchInput } from 'components/SearchInput'
@ -32,11 +32,13 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { FilterOptions, ModDetails, ModeratedFilter, MuteLists } from 'types' import { FilterOptions, ModDetails, ModeratedFilter, MuteLists } from 'types'
import { import {
CurationSetIdentifiers,
DEFAULT_FILTER_OPTIONS, DEFAULT_FILTER_OPTIONS,
extractModData, extractModData,
isModDataComplete, isModDataComplete,
scrollIntoView scrollIntoView
} from 'utils' } from 'utils'
import { useCuratedSet } from 'hooks/useCuratedSet'
enum SearchKindEnum { enum SearchKindEnum {
Mods = 'Mods', Mods = 'Mods',
@ -50,6 +52,7 @@ export const SearchPage = () => {
const muteLists = useMuteLists() const muteLists = useMuteLists()
const nsfwList = useNSFWList() const nsfwList = useNSFWList()
const repostList = useCuratedSet(CurationSetIdentifiers.Repost)
const searchTermRef = useRef<HTMLInputElement>(null) const searchTermRef = useRef<HTMLInputElement>(null)
const searchKind = const searchKind =
@ -115,6 +118,7 @@ export const SearchPage = () => {
filterOptions={filterOptions} filterOptions={filterOptions}
muteLists={muteLists} muteLists={muteLists}
nsfwList={nsfwList} nsfwList={nsfwList}
repostList={repostList}
el={scrollTargetRef.current} el={scrollTargetRef.current}
/> />
)} )}
@ -233,6 +237,7 @@ type ModsResultProps = {
user: MuteLists user: MuteLists
} }
nsfwList: string[] nsfwList: string[]
repostList: string[]
el: HTMLElement | null el: HTMLElement | null
} }
@ -241,6 +246,7 @@ const ModsResult = ({
searchTerm, searchTerm,
muteLists, muteLists,
nsfwList, nsfwList,
repostList,
el el
}: ModsResultProps) => { }: ModsResultProps) => {
const { ndk } = useNDKContext() const { ndk } = useNDKContext()
@ -313,7 +319,8 @@ const ModsResult = ({
userState, userState,
filterOptions, filterOptions,
nsfwList, nsfwList,
muteLists muteLists,
repostList
) )
const handleNext = () => { const handleNext = () => {

View File

@ -5,12 +5,15 @@ 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 { modsRouteLoader } from '../pages/mods/loader'
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 +21,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 +35,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',
@ -84,11 +88,19 @@ export const routerWithNdkContext = (context: NDKContextType) =>
}, },
{ {
path: appRoutes.mods, path: appRoutes.mods,
element: <ModsPage /> element: <ModsPage />,
loader: modsRouteLoader(context)
}, },
{ {
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 +127,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,

View File

@ -1,3 +1,5 @@
import { SortBy, NSFWFilter, ModeratedFilter } from './modsFilter'
export interface BlogForm { export interface BlogForm {
title: string title: string
content: string content: string
@ -40,3 +42,10 @@ export interface BlogPageLoaderResult {
isAddedToNSFW: boolean isAddedToNSFW: boolean
isBlocked: boolean isBlocked: boolean
} }
export interface BlogsFilterOptions {
sort: SortBy
nsfw: NSFWFilter
source: string
moderated: ModeratedFilter
}

View File

@ -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,8 @@ export interface ModFormState {
featuredImageUrl: string featuredImageUrl: string
summary: string summary: string
nsfw: boolean nsfw: boolean
repost: boolean
originalAuthor?: string
screenshotsUrls: string[] screenshotsUrls: string[]
tags: string tags: string
downloadUrls: DownloadUrl[] downloadUrls: DownloadUrl[]
@ -52,3 +55,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
}

View File

@ -25,10 +25,17 @@ export enum WOTFilterOptions {
Exclude = 'Exclude' Exclude = 'Exclude'
} }
export enum RepostFilter {
Hide_Repost = 'Hide Repost',
Show_Repost = 'Show Repost',
Only_Repost = 'Only Repost'
}
export interface FilterOptions { export interface FilterOptions {
sort: SortBy sort: SortBy
nsfw: NSFWFilter nsfw: NSFWFilter
source: string source: string
moderated: ModeratedFilter moderated: ModeratedFilter
wot: WOTFilterOptions wot: WOTFilterOptions
repost: RepostFilter
} }

4
src/types/report.ts Normal file
View File

@ -0,0 +1,4 @@
export interface ReportReason {
label: string
key: string
}

View File

@ -3,7 +3,8 @@ import {
SortBy, SortBy,
NSFWFilter, NSFWFilter,
ModeratedFilter, ModeratedFilter,
WOTFilterOptions WOTFilterOptions,
RepostFilter
} from 'types' } from 'types'
export const DEFAULT_FILTER_OPTIONS: FilterOptions = { export const DEFAULT_FILTER_OPTIONS: FilterOptions = {
@ -11,5 +12,6 @@ export const DEFAULT_FILTER_OPTIONS: FilterOptions = {
nsfw: NSFWFilter.Hide_NSFW, nsfw: NSFWFilter.Hide_NSFW,
source: window.location.host, source: window.location.host,
moderated: ModeratedFilter.Moderated, moderated: ModeratedFilter.Moderated,
wot: WOTFilterOptions.Site_Only wot: WOTFilterOptions.Site_Only,
repost: RepostFilter.Show_Repost
} }

187
src/utils/curationSets.ts Normal file
View File

@ -0,0 +1,187 @@
import { NDKFilter, NDKList } 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, npubToHex, 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 getReportingSet(
dTag: CurationSetIdentifiers,
ndkContext: NDKContextType
) {
const result: string[] = []
const reportingNpub = import.meta.env.VITE_REPORTING_NPUB
const hexKey = npubToHex(reportingNpub)
if (hexKey) {
const event = await ndkContext.fetchEvent({
kinds: [kinds.Curationsets],
authors: [hexKey],
'#d': [dTag]
})
if (event) {
const list = NDKList.from(event)
list.items.forEach((item) => {
if (item[0] === 'a') {
result.push(item[1])
}
})
}
}
return result
}
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
}

View File

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

View File

@ -39,6 +39,8 @@ 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',
originalAuthor: getFirstTagValue('originalAuthor'),
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 +120,8 @@ 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,
originalAuthor: existingModData?.originalAuthor || undefined,
screenshotsUrls: existingModData?.screenshotsUrls || [''], screenshotsUrls: existingModData?.screenshotsUrls || [''],
tags: existingModData?.tags.join(',') || '', tags: existingModData?.tags.join(',') || '',
downloadUrls: existingModData?.downloadUrls || [ downloadUrls: existingModData?.downloadUrls || [