mods refactor and added repost tag system #163
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,2 +1,3 @@
|
|||||||
Game Name,16 by 9 image,Boxart image
|
Game Name,16 by 9 image,Boxart image
|
||||||
Marvel's Spider-Man 2,,https://image.nostr.build/b5ef5ef8bd99daab73148145b024a1e6177160fd287ce829f82ba46e821490b6.jpg
|
Marvel's Spider-Man 2,,https://image.nostr.build/b5ef5ef8bd99daab73148145b024a1e6177160fd287ce829f82ba46e821490b6.jpg
|
||||||
|
S.T.A.L.K.E.R. 2: Heart of Chornobyl,,https://image.nostr.build/f5b61071bebcc8deccfd71362696fb708649b9c528bec1c6964262ded4157843.jpg
|
|
165
src/components/Filters/BlogsFilter.tsx
Normal file
165
src/components/Filters/BlogsFilter.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
25
src/components/Filters/Dropdown.tsx
Normal file
25
src/components/Filters/Dropdown.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
182
src/components/Filters/ModsFilter.tsx
Normal file
182
src/components/Filters/ModsFilter.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
16
src/components/Filters/Option.tsx
Normal file
16
src/components/Filters/Option.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
9
src/components/Filters/index.tsx
Normal file
9
src/components/Filters/index.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { PropsWithChildren } from 'react'
|
||||||
|
|
||||||
|
export const Filter = ({ children }: PropsWithChildren) => {
|
||||||
|
return (
|
||||||
|
<div className='IBMSecMain'>
|
||||||
|
<div className='FiltersMain'>{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -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
|
||||||
|
@ -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}...`} />
|
||||||
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
48
src/components/OriginalAuthor.tsx
Normal file
48
src/components/OriginalAuthor.tsx
Normal 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
|
||||||
|
}
|
15
src/components/ProfileLink.tsx
Normal file
15
src/components/ProfileLink.tsx
Normal 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>
|
||||||
|
}
|
@ -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}
|
15
src/hooks/useCuratedSet.tsx
Normal file
15
src/hooks/useCuratedSet.tsx
Normal 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
|
||||||
|
}
|
@ -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,
|
||||||
|
@ -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)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
@ -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'>
|
||||||
|
@ -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
|
||||||
|
@ -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
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
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
253
src/pages/mod/loader.ts
Normal file
253
src/pages/mod/loader.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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
77
src/pages/mods/loader.ts
Normal 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
|
||||||
|
}
|
@ -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) => (
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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 = () => {
|
||||||
|
@ -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,
|
||||||
|
@ -163,11 +163,21 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: end;
|
justify-content: end;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 15px;
|
|
||||||
color: rgba(255,255,255,0.75);
|
color: rgba(255,255,255,0.75);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: inset 0 0 8px 0 rgb(0,0,0,0.1);
|
box-shadow: inset 0 0 8px 0 rgb(0,0,0,0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.IBMSMSMBSSPostBodyHideText {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: rgb(55 55 55);
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.IBMSMSMBSSModFor {
|
.IBMSMSMBSSModFor {
|
||||||
@ -242,7 +252,7 @@
|
|||||||
padding: 2px;
|
padding: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.IBMSMSMBSSPostBody > div > div > p {
|
.IBMSMSMBSSPostBody > div:first-child > div > p {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -273,3 +283,9 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.IBMSMSMBSSPostBodyHideText > * {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
@ -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
|
||||||
|
}
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
@ -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
4
src/types/report.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export interface ReportReason {
|
||||||
|
label: string
|
||||||
|
key: string
|
||||||
|
}
|
@ -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
187
src/utils/curationSets.ts
Normal 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
|
||||||
|
}
|
@ -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,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 || [
|
||||||
|
Loading…
x
Reference in New Issue
Block a user