blog post and other fixes/additions #124
264
src/pages/blog/action.ts
Normal file
264
src/pages/blog/action.ts
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
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 { log, LogType, now, signAndPublish } from 'utils'
|
||||||
|
|
||||||
|
export const blogRouteAction =
|
||||||
|
(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}`)
|
||||||
|
const { identifier, kind, pubkey } = decoded.data
|
||||||
|
aTag = `${kind}:${pubkey}:${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(`Blog 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 filter: NDKFilter = {
|
||||||
|
kinds: [kinds.Curationsets],
|
||||||
|
authors: [hexPubkey],
|
||||||
|
'#d': ['nsfw']
|
||||||
|
}
|
||||||
|
|
||||||
|
const nsfwListEvent = await ndkContext.fetchEventFromUserRelays(
|
||||||
|
filter,
|
||||||
|
hexPubkey,
|
||||||
|
UserRelaysType.Write
|
||||||
|
)
|
||||||
|
|
||||||
|
let unsignedEvent: UnsignedEvent
|
||||||
|
|
||||||
|
if (nsfwListEvent) {
|
||||||
|
const tags = nsfwListEvent.tags
|
||||||
|
const alreadyExists =
|
||||||
|
tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== -1
|
||||||
|
|
||||||
|
if (alreadyExists) {
|
||||||
|
toast.warn(`Blog reference is already in user's nsfw list`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
tags.push(['a', aTag])
|
||||||
|
|
||||||
|
unsignedEvent = {
|
||||||
|
pubkey: nsfwListEvent.pubkey,
|
||||||
|
kind: kinds.Curationsets,
|
||||||
|
content: nsfwListEvent.content,
|
||||||
|
created_at: now(),
|
||||||
|
tags: [...tags]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
unsignedEvent = {
|
||||||
|
pubkey: hexPubkey,
|
||||||
|
kind: kinds.Curationsets,
|
||||||
|
content: '',
|
||||||
|
created_at: now(),
|
||||||
|
tags: [
|
||||||
|
['a', aTag],
|
||||||
|
['d', 'nsfw']
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUpdated = await signAndPublish(
|
||||||
|
unsignedEvent,
|
||||||
|
ndkContext.ndk,
|
||||||
|
ndkContext.publish
|
||||||
|
)
|
||||||
|
if (!isUpdated) {
|
||||||
|
toast.error("Failed to update user's nsfw list")
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const handleRemoveNSFW = async () => {
|
||||||
|
const filter: NDKFilter = {
|
||||||
|
kinds: [kinds.Curationsets],
|
||||||
|
authors: [hexPubkey],
|
||||||
|
'#d': ['nsfw']
|
||||||
|
}
|
||||||
|
|
||||||
|
const nsfwListEvent = await ndkContext.fetchEventFromUserRelays(
|
||||||
|
filter,
|
||||||
|
hexPubkey,
|
||||||
|
UserRelaysType.Write
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!nsfwListEvent) {
|
||||||
|
toast.error(`Couldn't get nsfw list event from relays`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = nsfwListEvent.tags
|
||||||
|
|
||||||
|
const unsignedEvent: UnsignedEvent = {
|
||||||
|
pubkey: nsfwListEvent.pubkey,
|
||||||
|
kind: kinds.Curationsets,
|
||||||
|
content: nsfwListEvent.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 nsfw list")
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestData = (await request.json()) as {
|
||||||
|
intent: 'nsfw' | 'block'
|
||||||
|
value: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (requestData.intent) {
|
||||||
|
case 'block':
|
||||||
|
await (requestData.value ? handleBlock() : handleUnblock())
|
||||||
|
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 blog action')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
@ -1,5 +1,10 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useLoaderData } from 'react-router-dom'
|
import {
|
||||||
|
useLoaderData,
|
||||||
|
Link as ReactRouterLink,
|
||||||
|
useNavigation,
|
||||||
|
useSubmit
|
||||||
|
} from 'react-router-dom'
|
||||||
import StarterKit from '@tiptap/starter-kit'
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
import Link from '@tiptap/extension-link'
|
import Link from '@tiptap/extension-link'
|
||||||
import Image from '@tiptap/extension-image'
|
import Image from '@tiptap/extension-image'
|
||||||
@ -14,9 +19,19 @@ import placeholder from '../../assets/img/DEGMods Placeholder Img.png'
|
|||||||
import { PublishDetails } from 'components/Internal/PublishDetails'
|
import { PublishDetails } from 'components/Internal/PublishDetails'
|
||||||
import { Interactions } from 'components/Internal/Interactions'
|
import { Interactions } from 'components/Internal/Interactions'
|
||||||
import { BlogCard } from 'components/BlogCard'
|
import { BlogCard } from 'components/BlogCard'
|
||||||
|
import { copyTextToClipboard } from 'utils'
|
||||||
|
import { toast } from 'react-toastify'
|
||||||
|
import { useAppSelector, useBodyScrollDisable } from 'hooks'
|
||||||
|
import { ReportPopup } from './report'
|
||||||
|
|
||||||
export const BlogPage = () => {
|
export const BlogPage = () => {
|
||||||
const { blog, latest } = useLoaderData() as BlogPageLoaderResult
|
const { blog, latest, isAddedToNSFW, isBlocked } =
|
||||||
|
useLoaderData() as BlogPageLoaderResult
|
||||||
|
const userState = useAppSelector((state) => state.user)
|
||||||
|
const isAdmin =
|
||||||
|
userState.user?.npub &&
|
||||||
|
userState.user.npub === import.meta.env.VITE_REPORTING_NPUB
|
||||||
|
const navigation = useNavigation()
|
||||||
const [commentCount, setCommentCount] = useState(0)
|
const [commentCount, setCommentCount] = useState(0)
|
||||||
const html = marked.parse(blog?.content || '', { async: false })
|
const html = marked.parse(blog?.content || '', { async: false })
|
||||||
const sanitized = DOMPurify.sanitize(html)
|
const sanitized = DOMPurify.sanitize(html)
|
||||||
@ -38,6 +53,42 @@ export const BlogPage = () => {
|
|||||||
[sanitized]
|
[sanitized]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const [showReportPopUp, setShowReportPopUp] = useState(false)
|
||||||
|
useBodyScrollDisable(showReportPopUp)
|
||||||
|
|
||||||
|
const submit = useSubmit()
|
||||||
|
const handleBlock = () => {
|
||||||
|
if (navigation.state === 'idle') {
|
||||||
|
submit(
|
||||||
|
{
|
||||||
|
intent: 'block',
|
||||||
|
value: !isBlocked,
|
||||||
|
target: blog?.aTag || ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'post',
|
||||||
|
encType: 'application/json'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNSFW = () => {
|
||||||
|
if (navigation.state === 'idle') {
|
||||||
|
submit(
|
||||||
|
{
|
||||||
|
intent: 'nsfw',
|
||||||
|
value: !isAddedToNSFW,
|
||||||
|
target: blog?.aTag || ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'post',
|
||||||
|
encType: 'application/json'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='InnerBodyMain'>
|
<div className='InnerBodyMain'>
|
||||||
<div className='ContainerMain'>
|
<div className='ContainerMain'>
|
||||||
@ -46,90 +97,223 @@ export const BlogPage = () => {
|
|||||||
{!blog ? (
|
{!blog ? (
|
||||||
<LoadingSpinner desc={'Loading...'} />
|
<LoadingSpinner desc={'Loading...'} />
|
||||||
) : (
|
) : (
|
||||||
<div className='IBMSMSplitMainBigSide'>
|
<>
|
||||||
<div className='IBMSMSplitMainBigSideSec'>
|
<div className='IBMSMSplitMainBigSide'>
|
||||||
{/* <div className="IBMSMSMBSSModFor">
|
<div className='IBMSMSplitMainBigSideSec'>
|
||||||
<p className="IBMSMSMBSSModForPara">Post for: <a className="IBMSMSMBSSModForLink" href="mods-inner.html">Mod Name</a></p>
|
<div className='IBMSMSMBSSPost'>
|
||||||
<div className="dropdown dropdownMain" style="flex-grow: unset;"><button className="btn btnMain btnMainDropdown" aria-expanded="false" data-bs-toggle="dropdown" type="button" style="border-radius: 5px;background: unset;padding: 5px;"><svg xmlns="http://www.w3.org/2000/svg" viewBox="-192 0 512 512" width="1em" height="1em" fill="currentColor">
|
<div
|
||||||
<path d="M64 360C94.93 360 120 385.1 120 416C120 446.9 94.93 472 64 472C33.07 472 8 446.9 8 416C8 385.1 33.07 360 64 360zM64 200C94.93 200 120 225.1 120 256C120 286.9 94.93 312 64 312C33.07 312 8 286.9 8 256C8 225.1 33.07 200 64 200zM64 152C33.07 152 8 126.9 8 96C8 65.07 33.07 40 64 40C94.93 40 120 65.07 120 96C120 126.9 94.93 152 64 152z"></path>
|
className='dropdown dropdownMain dropdownMainBlogpost'
|
||||||
</svg></button>
|
style={{ flexGrow: 'unset' }}
|
||||||
<div className="dropdown-menu dropdown-menu-end dropdownMainMenu"><a className="dropdown-item dropdownMainMenuItem" href="submit-mod.html"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="1em" height="1em" fill="currentColor" className="IBMSMSMSSS_Author_Top_Icon">
|
>
|
||||||
<path d="M362.7 19.32C387.7-5.678 428.3-5.678 453.3 19.32L492.7 58.75C517.7 83.74 517.7 124.3 492.7 149.3L444.3 197.7L314.3 67.72L362.7 19.32zM421.7 220.3L188.5 453.4C178.1 463.8 165.2 471.5 151.1 475.6L30.77 511C22.35 513.5 13.24 511.2 7.03 504.1C.8198 498.8-1.502 489.7 .976 481.2L36.37 360.9C40.53 346.8 48.16 333.9 58.57 323.5L291.7 90.34L421.7 220.3z"></path>
|
<button
|
||||||
</svg>Edit</a><a className="dropdown-item dropdownMainMenuItem" href="#"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="1em" height="1em" fill="currentColor" className="IBMSMSMSSS_Author_Top_Icon">
|
className='btn btnMain btnMainDropdown'
|
||||||
<path d="M384 96L384 0h-112c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48H464c26.51 0 48-21.49 48-48V128h-95.1C398.4 128 384 113.6 384 96zM416 0v96h96L416 0zM192 352V128h-144c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48h192c26.51 0 48-21.49 48-48L288 416h-32C220.7 416 192 387.3 192 352z"></path>
|
aria-expanded='false'
|
||||||
</svg>Copy URL</a><a className="dropdown-item dropdownMainMenuItem" href="#"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="1em" height="1em" fill="currentColor" className="IBMSMSMSSS_Author_Top_Icon">
|
data-bs-toggle='dropdown'
|
||||||
<path d="M503.7 226.2l-176 151.1c-15.38 13.3-39.69 2.545-39.69-18.16V272.1C132.9 274.3 66.06 312.8 111.4 457.8c5.031 16.09-14.41 28.56-28.06 18.62C39.59 444.6 0 383.8 0 322.3c0-152.2 127.4-184.4 288-186.3V56.02c0-20.67 24.28-31.46 39.69-18.16l176 151.1C514.8 199.4 514.8 216.6 503.7 226.2z"></path>
|
type='button'
|
||||||
</svg>Share</a><a className="dropdown-item dropdownMainMenuItem" id="reportPost" href="#"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="1em" height="1em" fill="currentColor" className="IBMSMSMSSS_Author_Top_Icon">
|
style={{
|
||||||
<path d="M506.3 417l-213.3-364c-16.33-28-57.54-28-73.98 0l-213.2 364C-10.59 444.9 9.849 480 42.74 480h426.6C502.1 480 522.6 445 506.3 417zM232 168c0-13.25 10.75-24 24-24S280 154.8 280 168v128c0 13.25-10.75 24-23.1 24S232 309.3 232 296V168zM256 416c-17.36 0-31.44-14.08-31.44-31.44c0-17.36 14.07-31.44 31.44-31.44s31.44 14.08 31.44 31.44C287.4 401.9 273.4 416 256 416z"></path>
|
borderRadius: '5px',
|
||||||
</svg>Report</a><a className="dropdown-item dropdownMainMenuItem" href="#"><svg xmlns="http://www.w3.org/2000/svg" viewBox="-32 0 512 512" width="1em" height="1em" fill="currentColor" className="IBMSMSMSSS_Author_Top_Icon">
|
background: 'unset',
|
||||||
<path d="M323.5 51.25C302.8 70.5 284 90.75 267.4 111.1C240.1 73.62 206.2 35.5 168 0C69.75 91.12 0 210 0 281.6C0 408.9 100.2 512 224 512s224-103.1 224-230.4C448 228.4 396 118.5 323.5 51.25zM304.1 391.9C282.4 407 255.8 416 226.9 416c-72.13 0-130.9-47.73-130.9-125.2c0-38.63 24.24-72.64 72.74-130.8c7 8 98.88 125.4 98.88 125.4l58.63-66.88c4.125 6.75 7.867 13.52 11.24 19.9C364.9 290.6 353.4 357.4 304.1 391.9z"></path>
|
padding: '5px'
|
||||||
</svg>Block Post</a></div>
|
}}
|
||||||
</div>
|
>
|
||||||
</div> */}
|
<svg
|
||||||
<div className='IBMSMSMBSSPost'>
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
<div
|
viewBox='-192 0 512 512'
|
||||||
className='IBMSMSMBSSPostPicture'
|
width='1em'
|
||||||
style={{
|
height='1em'
|
||||||
background: `url("${
|
fill='currentColor'
|
||||||
blog.image !== '' ? blog.image : placeholder
|
>
|
||||||
}") center / cover no-repeat`
|
<path d='M64 360C94.93 360 120 385.1 120 416C120 446.9 94.93 472 64 472C33.07 472 8 446.9 8 416C8 385.1 33.07 360 64 360zM64 200C94.93 200 120 225.1 120 256C120 286.9 94.93 312 64 312C33.07 312 8 286.9 8 256C8 225.1 33.07 200 64 200zM64 152C33.07 152 8 126.9 8 96C8 65.07 33.07 40 64 40C94.93 40 120 65.07 120 96C120 126.9 94.93 152 64 152z'></path>
|
||||||
}}
|
</svg>
|
||||||
></div>
|
</button>
|
||||||
<div className='IBMSMSMBSSPostInside'>
|
<div
|
||||||
<div className='IBMSMSMBSSPostTitle'>
|
className={`dropdown-menu dropdown-menu-end dropdownMainMenu`}
|
||||||
<h1 className='IBMSMSMBSSPostTitleHeading'>
|
>
|
||||||
{blog.title}
|
{userState.auth &&
|
||||||
</h1>
|
userState.user?.pubkey === blog.author && (
|
||||||
</div>
|
<ReactRouterLink
|
||||||
<div className='IBMSMSMBSSPostBody'>
|
className='dropdown-item dropdownMainMenuItem'
|
||||||
<EditorContent editor={editor} />
|
to={'edit'}
|
||||||
</div>
|
>
|
||||||
<div className='IBMSMSMBSSTags'>
|
<svg
|
||||||
{blog.nsfw && (
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
<div className='IBMSMSMBSSTagsTag IBMSMSMBSSTagsTagNSFW'>
|
viewBox='0 0 512 512'
|
||||||
<p>NSFW</p>
|
width='1em'
|
||||||
</div>
|
height='1em'
|
||||||
)}
|
fill='currentColor'
|
||||||
{blog.tTags &&
|
className='IBMSMSMSSS_Author_Top_Icon'
|
||||||
blog.tTags.map((t) => (
|
>
|
||||||
<a key={t} className='IBMSMSMBSSTagsTag'>
|
<path d='M362.7 19.32C387.7-5.678 428.3-5.678 453.3 19.32L492.7 58.75C517.7 83.74 517.7 124.3 492.7 149.3L444.3 197.7L314.3 67.72L362.7 19.32zM421.7 220.3L188.5 453.4C178.1 463.8 165.2 471.5 151.1 475.6L30.77 511C22.35 513.5 13.24 511.2 7.03 504.1C.8198 498.8-1.502 489.7 .976 481.2L36.37 360.9C40.53 346.8 48.16 333.9 58.57 323.5L291.7 90.34L421.7 220.3z'></path>
|
||||||
{t}
|
</svg>
|
||||||
|
Edit
|
||||||
|
</ReactRouterLink>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<a
|
||||||
|
className='dropdown-item dropdownMainMenuItem'
|
||||||
|
onClick={() => {
|
||||||
|
copyTextToClipboard(window.location.href).then(
|
||||||
|
(isCopied) => {
|
||||||
|
if (isCopied)
|
||||||
|
toast.success('Url copied to clipboard!')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
viewBox='0 0 512 512'
|
||||||
|
width='1em'
|
||||||
|
height='1em'
|
||||||
|
fill='currentColor'
|
||||||
|
className='IBMSMSMSSS_Author_Top_Icon'
|
||||||
|
>
|
||||||
|
<path d='M384 96L384 0h-112c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48H464c26.51 0 48-21.49 48-48V128h-95.1C398.4 128 384 113.6 384 96zM416 0v96h96L416 0zM192 352V128h-144c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48h192c26.51 0 48-21.49 48-48L288 416h-32C220.7 416 192 387.3 192 352z'></path>
|
||||||
|
</svg>
|
||||||
|
Copy URL
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className='dropdown-item dropdownMainMenuItem'
|
||||||
|
href='#'
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
viewBox='0 0 512 512'
|
||||||
|
width='1em'
|
||||||
|
height='1em'
|
||||||
|
fill='currentColor'
|
||||||
|
className='IBMSMSMSSS_Author_Top_Icon'
|
||||||
|
>
|
||||||
|
<path d='M503.7 226.2l-176 151.1c-15.38 13.3-39.69 2.545-39.69-18.16V272.1C132.9 274.3 66.06 312.8 111.4 457.8c5.031 16.09-14.41 28.56-28.06 18.62C39.59 444.6 0 383.8 0 322.3c0-152.2 127.4-184.4 288-186.3V56.02c0-20.67 24.28-31.46 39.69-18.16l176 151.1C514.8 199.4 514.8 216.6 503.7 226.2z'></path>
|
||||||
|
</svg>
|
||||||
|
Share
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
className='dropdown-item dropdownMainMenuItem'
|
||||||
|
id='reportPost'
|
||||||
|
onClick={() => setShowReportPopUp(true)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
viewBox='0 0 512 512'
|
||||||
|
width='1em'
|
||||||
|
height='1em'
|
||||||
|
fill='currentColor'
|
||||||
|
className='IBMSMSMSSS_Author_Top_Icon'
|
||||||
|
>
|
||||||
|
<path d='M506.3 417l-213.3-364c-16.33-28-57.54-28-73.98 0l-213.2 364C-10.59 444.9 9.849 480 42.74 480h426.6C502.1 480 522.6 445 506.3 417zM232 168c0-13.25 10.75-24 24-24S280 154.8 280 168v128c0 13.25-10.75 24-23.1 24S232 309.3 232 296V168zM256 416c-17.36 0-31.44-14.08-31.44-31.44c0-17.36 14.07-31.44 31.44-31.44s31.44 14.08 31.44 31.44C287.4 401.9 273.4 416 256 416z'></path>
|
||||||
|
</svg>
|
||||||
|
Report
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
className='dropdown-item dropdownMainMenuItem'
|
||||||
|
onClick={handleBlock}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
viewBox='-32 0 512 512'
|
||||||
|
width='1em'
|
||||||
|
height='1em'
|
||||||
|
fill='currentColor'
|
||||||
|
className='IBMSMSMSSS_Author_Top_Icon'
|
||||||
|
>
|
||||||
|
<path d='M323.5 51.25C302.8 70.5 284 90.75 267.4 111.1C240.1 73.62 206.2 35.5 168 0C69.75 91.12 0 210 0 281.6C0 408.9 100.2 512 224 512s224-103.1 224-230.4C448 228.4 396 118.5 323.5 51.25zM304.1 391.9C282.4 407 255.8 416 226.9 416c-72.13 0-130.9-47.73-130.9-125.2c0-38.63 24.24-72.64 65.13-83.3c10.14-2.656 19.94 4.78 19.94 15.27c0 6.941-4.469 13.16-11.16 15.19c-17.5 4.578-34.41 23.94-34.41 52.84c0 50.81 39.31 94.81 91.41 94.81c24.66 0 45.22-6.5 63.19-18.75c11.75-8 27.91 3.469 23.91 16.69C314.6 384.7 309.8 388.4 304.1 391.9z'></path>
|
||||||
|
</svg>
|
||||||
|
{isBlocked ? 'Unblock' : 'Block'} Blog
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<a
|
||||||
|
className='dropdown-item dropdownMainMenuItem'
|
||||||
|
onClick={handleNSFW}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
viewBox='-32 0 512 512'
|
||||||
|
width='1em'
|
||||||
|
height='1em'
|
||||||
|
fill='currentColor'
|
||||||
|
className='IBMSMSMSSS_Author_Top_Icon'
|
||||||
|
>
|
||||||
|
<path d='M323.5 51.25C302.8 70.5 284 90.75 267.4 111.1C240.1 73.62 206.2 35.5 168 0C69.75 91.12 0 210 0 281.6C0 408.9 100.2 512 224 512s224-103.1 224-230.4C448 228.4 396 118.5 323.5 51.25zM304.1 391.9C282.4 407 255.8 416 226.9 416c-72.13 0-130.9-47.73-130.9-125.2c0-38.63 24.24-72.64 65.13-83.3c10.14-2.656 19.94 4.78 19.94 15.27c0 6.941-4.469 13.16-11.16 15.19c-17.5 4.578-34.41 23.94-34.41 52.84c0 50.81 39.31 94.81 91.41 94.81c24.66 0 45.22-6.5 63.19-18.75c11.75-8 27.91 3.469 23.91 16.69C314.6 384.7 309.8 388.4 304.1 391.9z'></path>
|
||||||
|
</svg>
|
||||||
|
{isAddedToNSFW ? 'Un-mark' : 'Mark'} as NSFW
|
||||||
</a>
|
</a>
|
||||||
))}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div
|
||||||
</div>
|
className='IBMSMSMBSSPostPicture'
|
||||||
<Interactions
|
style={{
|
||||||
addressable={blog as Addressable}
|
background: `url("${
|
||||||
commentCount={commentCount}
|
blog.image !== '' ? blog.image : placeholder
|
||||||
/>
|
}") center / cover no-repeat`
|
||||||
<PublishDetails
|
}}
|
||||||
published_at={blog.published_at || 0}
|
></div>
|
||||||
edited_at={blog.edited_at || 0}
|
<div className='IBMSMSMBSSPostInside'>
|
||||||
site={blog.rTag || 'N/A'}
|
<div className='IBMSMSMBSSPostTitle'>
|
||||||
/>
|
<h1 className='IBMSMSMBSSPostTitleHeading'>
|
||||||
{!!latest.length && (
|
{blog.title}
|
||||||
<div className='IBMSMSplitMainBigSideSec'>
|
</h1>
|
||||||
<div className='IBMSMSMBSSPostsWrapper'>
|
</div>
|
||||||
<h4 className='IBMSMSMBSSPostsTitle'>
|
<div className='IBMSMSMBSSPostBody'>
|
||||||
Latest blog posts
|
<EditorContent editor={editor} />
|
||||||
</h4>
|
</div>
|
||||||
<div className='IBMSMList IBMSMListAlt'>
|
<div className='IBMSMSMBSSTags'>
|
||||||
{latest.map((b) => (
|
{blog.nsfw && (
|
||||||
<BlogCard key={b.id} {...b} />
|
<div className='IBMSMSMBSSTagsTag IBMSMSMBSSTagsTagNSFW'>
|
||||||
))}
|
<p>NSFW</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{blog.tTags &&
|
||||||
|
blog.tTags.map((t) => (
|
||||||
|
<a key={t} className='IBMSMSMBSSTagsTag'>
|
||||||
|
{t}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<Interactions
|
||||||
<div className='IBMSMSplitMainBigSideSec'>
|
|
||||||
<Comments
|
|
||||||
addressable={blog as Addressable}
|
addressable={blog as Addressable}
|
||||||
setCommentCount={setCommentCount}
|
commentCount={commentCount}
|
||||||
/>
|
/>
|
||||||
|
<PublishDetails
|
||||||
|
published_at={blog.published_at || 0}
|
||||||
|
edited_at={blog.edited_at || 0}
|
||||||
|
site={blog.rTag || 'N/A'}
|
||||||
|
/>
|
||||||
|
{!!latest.length && (
|
||||||
|
<div className='IBMSMSplitMainBigSideSec'>
|
||||||
|
<div className='IBMSMSMBSSPostsWrapper'>
|
||||||
|
<h4 className='IBMSMSMBSSPostsTitle'>
|
||||||
|
Latest blog posts
|
||||||
|
</h4>
|
||||||
|
<div className='IBMSMList IBMSMListAlt'>
|
||||||
|
{latest.map((b) => (
|
||||||
|
<BlogCard key={b.id} {...b} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className='IBMSMSplitMainBigSideSec'>
|
||||||
|
<Comments
|
||||||
|
addressable={blog as Addressable}
|
||||||
|
setCommentCount={setCommentCount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{navigation.state !== 'idle' && (
|
||||||
|
<LoadingSpinner desc={'Loading...'} />
|
||||||
|
)}
|
||||||
|
{showReportPopUp && (
|
||||||
|
<ReportPopup handleClose={() => setShowReportPopUp(false)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{!!blog?.author && <ProfileSection pubkey={blog.author} />}
|
{!!blog?.author && <ProfileSection pubkey={blog.author} />}
|
||||||
</div>
|
</div>
|
||||||
|
@ -4,6 +4,7 @@ import { kinds, nip19 } from 'nostr-tools'
|
|||||||
import { LoaderFunctionArgs, redirect } from 'react-router-dom'
|
import { LoaderFunctionArgs, redirect } from 'react-router-dom'
|
||||||
import { toast } from 'react-toastify'
|
import { toast } from 'react-toastify'
|
||||||
import { appRoutes } from 'routes'
|
import { appRoutes } from 'routes'
|
||||||
|
import { store } from 'store'
|
||||||
import { BlogPageLoaderResult, FilterOptions, NSFWFilter } from 'types'
|
import { BlogPageLoaderResult, FilterOptions, NSFWFilter } from 'types'
|
||||||
import {
|
import {
|
||||||
DEFAULT_FILTER_OPTIONS,
|
DEFAULT_FILTER_OPTIONS,
|
||||||
@ -33,6 +34,10 @@ export const blogRouteLoader =
|
|||||||
log(true, LogType.Error, 'Unable to create filter from blog naddr.')
|
log(true, LogType.Error, 'Unable to create filter from blog naddr.')
|
||||||
return redirect(appRoutes.blogs)
|
return redirect(appRoutes.blogs)
|
||||||
}
|
}
|
||||||
|
// Update kinds to make sure we fetch correct event kind
|
||||||
|
filter.kinds = [kinds.LongFormArticle]
|
||||||
|
|
||||||
|
const userState = store.getState().user
|
||||||
|
|
||||||
// Get the blog filter options for latest blogs
|
// Get the blog filter options for latest blogs
|
||||||
const filterOptions = JSON.parse(
|
const filterOptions = JSON.parse(
|
||||||
@ -59,15 +64,19 @@ export const blogRouteLoader =
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parallel fetch blog event and latest events
|
// Parallel fetch blog event, latest events, mute, and nsfw lists in parallel
|
||||||
const settled = await Promise.allSettled([
|
const settled = await Promise.allSettled([
|
||||||
ndkContext.fetchEvent(filter),
|
ndkContext.fetchEvent(filter),
|
||||||
ndkContext.fetchEvents(latestModsFilter)
|
ndkContext.fetchEvents(latestModsFilter),
|
||||||
|
ndkContext.getMuteLists(userState?.user?.pubkey as string),
|
||||||
|
ndkContext.getNSFWList()
|
||||||
])
|
])
|
||||||
|
|
||||||
const result: BlogPageLoaderResult = {
|
const result: BlogPageLoaderResult = {
|
||||||
blog: undefined,
|
blog: undefined,
|
||||||
latest: []
|
latest: [],
|
||||||
|
isAddedToNSFW: false,
|
||||||
|
isBlocked: false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check the blog event result
|
// Check the blog event result
|
||||||
@ -101,6 +110,59 @@ export const blogRouteLoader =
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const muteList = settled[2]
|
||||||
|
if (muteList.status === 'fulfilled' && muteList.value) {
|
||||||
|
if (muteList && muteList.value) {
|
||||||
|
if (result.blog && result.blog.aTag) {
|
||||||
|
if (
|
||||||
|
muteList.value.admin.replaceableEvents.includes(
|
||||||
|
result.blog.aTag
|
||||||
|
) ||
|
||||||
|
muteList.value.user.replaceableEvents.includes(result.blog.aTag)
|
||||||
|
) {
|
||||||
|
result.isBlocked = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (muteList.status === 'rejected') {
|
||||||
|
log(true, LogType.Error, 'Issue fetching mute list', muteList.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nsfwList = settled[3]
|
||||||
|
if (nsfwList.status === 'fulfilled' && nsfwList.value) {
|
||||||
|
// Check if the blog is marked as NSFW
|
||||||
|
// Mark it as NSFW only if it's missing the tag
|
||||||
|
if (result.blog) {
|
||||||
|
const isMissingNsfwTag =
|
||||||
|
!result.blog.nsfw &&
|
||||||
|
result.blog.aTag &&
|
||||||
|
nsfwList.value.includes(result.blog.aTag)
|
||||||
|
|
||||||
|
if (isMissingNsfwTag) {
|
||||||
|
result.blog.nsfw = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.blog.aTag && nsfwList.value.includes(result.blog.aTag)) {
|
||||||
|
result.isAddedToNSFW = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the the latest blogs too
|
||||||
|
result.latest = result.latest.map((b) => {
|
||||||
|
if (b) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log(
|
log(
|
||||||
|
92
src/pages/blog/report.tsx
Normal file
92
src/pages/blog/report.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { useFetcher } from 'react-router-dom'
|
||||||
|
import { CheckboxFieldUncontrolled } from 'components/Inputs'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
type ReportPopupProps = {
|
||||||
|
handleClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ReportPopup = ({ handleClose }: ReportPopupProps) => {
|
||||||
|
const fetcher = useFetcher()
|
||||||
|
|
||||||
|
// Close automatically if action succeeds
|
||||||
|
useEffect(() => {
|
||||||
|
if (fetcher.data) {
|
||||||
|
const { isSent } = fetcher.data
|
||||||
|
console.log(fetcher.data)
|
||||||
|
if (isSent) {
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [fetcher, handleClose])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='popUpMain'>
|
||||||
|
<div className='ContainerMain'>
|
||||||
|
<div className='popUpMainCardWrapper'>
|
||||||
|
<div className='popUpMainCard popUpMainCardQR'>
|
||||||
|
<div className='popUpMainCardTop'>
|
||||||
|
<div className='popUpMainCardTopInfo'>
|
||||||
|
<h3>Report Post</h3>
|
||||||
|
</div>
|
||||||
|
<div className='popUpMainCardTopClose' onClick={handleClose}>
|
||||||
|
<svg
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
viewBox='-96 0 512 512'
|
||||||
|
width='1em'
|
||||||
|
height='1em'
|
||||||
|
fill='currentColor'
|
||||||
|
style={{ zIndex: 1 }}
|
||||||
|
>
|
||||||
|
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='pUMCB_Zaps'>
|
||||||
|
<fetcher.Form
|
||||||
|
className='pUMCB_ZapsInside'
|
||||||
|
method='post'
|
||||||
|
action='report'
|
||||||
|
>
|
||||||
|
<div className='inputLabelWrapperMain'>
|
||||||
|
<label
|
||||||
|
className='form-label labelMain'
|
||||||
|
style={{ fontWeight: 'bold' }}
|
||||||
|
>
|
||||||
|
Why are you reporting this?
|
||||||
|
</label>
|
||||||
|
{BLOG_REPORT_REASONS.map((r) => (
|
||||||
|
<CheckboxFieldUncontrolled
|
||||||
|
key={r.key}
|
||||||
|
label={r.label}
|
||||||
|
name={r.key}
|
||||||
|
defaultChecked={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className='btn btnMain pUMCB_Report'
|
||||||
|
type='submit'
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
Submit Report
|
||||||
|
</button>
|
||||||
|
</fetcher.Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
146
src/pages/blog/reportAction.ts
Normal file
146
src/pages/blog/reportAction.ts
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
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 {
|
||||||
|
log,
|
||||||
|
LogType,
|
||||||
|
now,
|
||||||
|
npubToHex,
|
||||||
|
parseFormData,
|
||||||
|
sendDMUsingRandomKey,
|
||||||
|
signAndPublish
|
||||||
|
} from 'utils'
|
||||||
|
|
||||||
|
export const blogReportRouteAction =
|
||||||
|
(ndkContext: NDKContextType) =>
|
||||||
|
async ({ params, request }: ActionFunctionArgs) => {
|
||||||
|
const requestData = await request.formData()
|
||||||
|
const { naddr } = params
|
||||||
|
if (!naddr) {
|
||||||
|
log(true, LogType.Error, 'Required naddr.')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode author from naddr
|
||||||
|
let aTag: string | undefined
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
|
||||||
|
const { identifier, kind, pubkey } = decoded.data
|
||||||
|
aTag = `${kind}:${pubkey}:${identifier}`
|
||||||
|
} catch (error) {
|
||||||
|
log(true, LogType.Error, 'Failed to decode naddr')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!aTag) {
|
||||||
|
log(true, LogType.Error, 'Missing #a Tag')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const userState = store.getState().user
|
||||||
|
let hexPubkey: string | undefined
|
||||||
|
if (userState.auth && userState.user?.pubkey) {
|
||||||
|
hexPubkey = userState.user.pubkey as string
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportingNpub = import.meta.env.VITE_REPORTING_NPUB
|
||||||
|
const reportingPubkey = npubToHex(reportingNpub)
|
||||||
|
|
||||||
|
// Parse the the data
|
||||||
|
const formSubmit = parseFormData(requestData)
|
||||||
|
|
||||||
|
const selectedOptionsCount = Object.values(formSubmit).filter(
|
||||||
|
(checked) => checked === 'on'
|
||||||
|
).length
|
||||||
|
if (selectedOptionsCount === 0) {
|
||||||
|
toast.error('At least one option should be checked!')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reportingPubkey === hexPubkey) {
|
||||||
|
// 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) {
|
||||||
|
const tags = muteListEvent.tags
|
||||||
|
const alreadyExists =
|
||||||
|
tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== -1
|
||||||
|
if (alreadyExists) {
|
||||||
|
toast.warn(`Blog reference is already in user's mute list`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
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]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
hexPubkey = await window.nostr?.getPublicKey()
|
||||||
|
} catch (error) {
|
||||||
|
log(
|
||||||
|
true,
|
||||||
|
LogType.Error,
|
||||||
|
'Could not get pubkey for reporting blog!',
|
||||||
|
error
|
||||||
|
)
|
||||||
|
toast.error('Could not get pubkey for reporting blog!')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUpdated = await signAndPublish(
|
||||||
|
unsignedEvent,
|
||||||
|
ndkContext.ndk,
|
||||||
|
ndkContext.publish
|
||||||
|
)
|
||||||
|
return { isSent: isUpdated }
|
||||||
|
} else {
|
||||||
|
const href = window.location.href
|
||||||
|
let message = `I'd like to report ${href} due to following reasons:\n`
|
||||||
|
Object.entries(formSubmit).forEach(([key, value]) => {
|
||||||
|
if (value === 'on') {
|
||||||
|
message += `* ${key}\n`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const isSent = await sendDMUsingRandomKey(
|
||||||
|
message,
|
||||||
|
reportingPubkey!,
|
||||||
|
ndkContext.ndk,
|
||||||
|
ndkContext.publish
|
||||||
|
)
|
||||||
|
return { isSent: isSent }
|
||||||
|
} catch (error) {
|
||||||
|
log(true, LogType.Error, 'Failed to send a blog report', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -381,7 +381,7 @@ const DisplayLatestBlogs = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const results = await Promise.allSettled([
|
const results = await Promise.allSettled([
|
||||||
fetchEvents(filter),
|
fetchEvents({ ...filter, kinds: [kinds.LongFormArticle] }),
|
||||||
fetchEvents(latestFilter)
|
fetchEvents(latestFilter)
|
||||||
])
|
])
|
||||||
|
|
||||||
|
@ -23,6 +23,7 @@ import {
|
|||||||
BlogCardDetails,
|
BlogCardDetails,
|
||||||
FilterOptions,
|
FilterOptions,
|
||||||
ModDetails,
|
ModDetails,
|
||||||
|
ModeratedFilter,
|
||||||
NSFWFilter,
|
NSFWFilter,
|
||||||
SortBy,
|
SortBy,
|
||||||
UserRelaysType
|
UserRelaysType
|
||||||
@ -687,7 +688,8 @@ const ReportUserPopup = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ProfileTabBlogs = () => {
|
const ProfileTabBlogs = () => {
|
||||||
const { profile } = useLoaderData() as ProfilePageLoaderResult
|
const { profile, muteLists, nsfwList } =
|
||||||
|
useLoaderData() as ProfilePageLoaderResult
|
||||||
const { fetchEvents } = useNDKContext()
|
const { fetchEvents } = useNDKContext()
|
||||||
const [filterOptions] = useLocalStorage('filter-blog', DEFAULT_FILTER_OPTIONS)
|
const [filterOptions] = useLocalStorage('filter-blog', DEFAULT_FILTER_OPTIONS)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
@ -779,20 +781,67 @@ const ProfileTabBlogs = () => {
|
|||||||
}
|
}
|
||||||
}, [blogfilter, blogs, fetchEvents, isLoading])
|
}, [blogfilter, blogs, fetchEvents, isLoading])
|
||||||
|
|
||||||
const sortedBlogs = useMemo(() => {
|
const userState = useAppSelector((state) => state.user)
|
||||||
const sorted = blogs || []
|
const moderatedAndSortedBlogs = useMemo(() => {
|
||||||
|
let _blogs = blogs || []
|
||||||
|
const isAdmin = userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
|
||||||
|
const isOwner =
|
||||||
|
userState.user?.pubkey && userState.user.pubkey === profile?.pubkey
|
||||||
|
const isUnmoderatedFully =
|
||||||
|
filterOptions.moderated === ModeratedFilter.Unmoderated_Fully
|
||||||
|
|
||||||
|
// Add nsfw tag to blogs included in nsfwList
|
||||||
|
if (filterOptions.nsfw !== NSFWFilter.Hide_NSFW) {
|
||||||
|
_blogs = _blogs.map((b) => {
|
||||||
|
return !b.nsfw && b.aTag && nsfwList.includes(b.aTag)
|
||||||
|
? { ...b, nsfw: true }
|
||||||
|
: b
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)) {
|
||||||
|
_blogs = _blogs.filter(
|
||||||
|
(b) =>
|
||||||
|
!muteLists.admin.authors.includes(b.author!) &&
|
||||||
|
!muteLists.admin.replaceableEvents.includes(b.aTag!)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterOptions.moderated === ModeratedFilter.Moderated) {
|
||||||
|
_blogs = _blogs.filter(
|
||||||
|
(b) =>
|
||||||
|
!muteLists.user.authors.includes(b.author!) &&
|
||||||
|
!muteLists.user.replaceableEvents.includes(b.aTag!)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (filterOptions.sort === SortBy.Latest) {
|
if (filterOptions.sort === SortBy.Latest) {
|
||||||
sorted.sort((a, b) =>
|
_blogs.sort((a, b) =>
|
||||||
a.published_at && b.published_at ? b.published_at - a.published_at : 0
|
a.published_at && b.published_at ? b.published_at - a.published_at : 0
|
||||||
)
|
)
|
||||||
} else if (filterOptions.sort === SortBy.Oldest) {
|
} else if (filterOptions.sort === SortBy.Oldest) {
|
||||||
sorted.sort((a, b) =>
|
_blogs.sort((a, b) =>
|
||||||
a.published_at && b.published_at ? a.published_at - b.published_at : 0
|
a.published_at && b.published_at ? a.published_at - b.published_at : 0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return sorted
|
return _blogs
|
||||||
}, [blogs, filterOptions.sort])
|
}, [
|
||||||
|
blogs,
|
||||||
|
filterOptions.moderated,
|
||||||
|
filterOptions.nsfw,
|
||||||
|
filterOptions.sort,
|
||||||
|
muteLists.admin.authors,
|
||||||
|
muteLists.admin.replaceableEvents,
|
||||||
|
muteLists.user.authors,
|
||||||
|
muteLists.user.replaceableEvents,
|
||||||
|
nsfwList,
|
||||||
|
profile?.pubkey,
|
||||||
|
userState.user?.npub,
|
||||||
|
userState.user?.pubkey
|
||||||
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -801,7 +850,7 @@ const ProfileTabBlogs = () => {
|
|||||||
<ModFilter filterKey={'filter-blog'} author={profile?.pubkey as string} />
|
<ModFilter filterKey={'filter-blog'} author={profile?.pubkey as string} />
|
||||||
|
|
||||||
<div className='IBMSMList IBMSMListAlt'>
|
<div className='IBMSMList IBMSMListAlt'>
|
||||||
{sortedBlogs.map((b) => (
|
{moderatedAndSortedBlogs.map((b) => (
|
||||||
<BlogCard key={b.id} {...b} />
|
<BlogCard key={b.id} {...b} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,23 +1,25 @@
|
|||||||
import { NDKFilter, NDKKind } from '@nostr-dev-kit/ndk'
|
|
||||||
import { NDKContextType } from 'contexts/NDKContext'
|
import { NDKContextType } from 'contexts/NDKContext'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { LoaderFunctionArgs, redirect } from 'react-router-dom'
|
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 { UserProfile, UserRelaysType } from 'types'
|
import { MuteLists, UserProfile } from 'types'
|
||||||
import { log, LogType } from 'utils'
|
import { log, LogType } from 'utils'
|
||||||
|
|
||||||
export interface ProfilePageLoaderResult {
|
export interface ProfilePageLoaderResult {
|
||||||
profile: UserProfile
|
profile: UserProfile
|
||||||
isBlocked: boolean
|
isBlocked: boolean
|
||||||
isOwnProfile: boolean
|
isOwnProfile: boolean
|
||||||
|
muteLists: {
|
||||||
|
admin: MuteLists
|
||||||
|
user: MuteLists
|
||||||
|
}
|
||||||
|
nsfwList: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const profileRouteLoader =
|
export const profileRouteLoader =
|
||||||
(ndkContext: NDKContextType) =>
|
(ndkContext: NDKContextType) =>
|
||||||
async ({ params }: LoaderFunctionArgs) => {
|
async ({ params }: LoaderFunctionArgs) => {
|
||||||
let profileRoute = appRoutes.home
|
|
||||||
|
|
||||||
// Try to decode nprofile parameter
|
// Try to decode nprofile parameter
|
||||||
const { nprofile } = params
|
const { nprofile } = params
|
||||||
let profilePubkey: string | undefined
|
let profilePubkey: string | undefined
|
||||||
@ -34,57 +36,94 @@ export const profileRouteLoader =
|
|||||||
// Get the current state
|
// Get the current state
|
||||||
const userState = store.getState().user
|
const userState = store.getState().user
|
||||||
|
|
||||||
// Redirect route
|
// Check if current user is logged in
|
||||||
// Redirect home if user is not logged in or profile naddr is missing
|
let userPubkey: string | undefined
|
||||||
if (!profilePubkey && userState.auth && userState.user?.pubkey) {
|
if (userState.auth && userState.user?.pubkey) {
|
||||||
// Redirect to user's profile is no profile is linked
|
userPubkey = userState.user.pubkey as string
|
||||||
const userHexKey = userState.user.pubkey as string
|
|
||||||
|
|
||||||
if (userHexKey) {
|
|
||||||
profileRoute = getProfilePageRoute(
|
|
||||||
nip19.nprofileEncode({
|
|
||||||
pubkey: userHexKey
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Redirect if profile naddr is missing
|
||||||
|
// - home if user is not logged
|
||||||
|
let profileRoute = appRoutes.home
|
||||||
|
if (!profilePubkey && userPubkey) {
|
||||||
|
// - own profile
|
||||||
|
profileRoute = getProfilePageRoute(
|
||||||
|
nip19.nprofileEncode({
|
||||||
|
pubkey: userPubkey
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
if (!profilePubkey) return redirect(profileRoute)
|
if (!profilePubkey) return redirect(profileRoute)
|
||||||
|
|
||||||
|
// Empty result
|
||||||
const result: ProfilePageLoaderResult = {
|
const result: ProfilePageLoaderResult = {
|
||||||
profile: {},
|
profile: {},
|
||||||
isBlocked: false,
|
isBlocked: false,
|
||||||
isOwnProfile: false
|
isOwnProfile: false,
|
||||||
|
muteLists: {
|
||||||
|
admin: {
|
||||||
|
authors: [],
|
||||||
|
replaceableEvents: []
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
authors: [],
|
||||||
|
replaceableEvents: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nsfwList: []
|
||||||
}
|
}
|
||||||
|
|
||||||
result.profile = await ndkContext.findMetadata(profilePubkey)
|
|
||||||
|
|
||||||
// Check if user the user is logged in
|
// Check if user the user is logged in
|
||||||
if (userState.auth && userState.user?.pubkey) {
|
if (userState.auth && userState.user?.pubkey) {
|
||||||
result.isOwnProfile = userState.user.pubkey === profilePubkey
|
result.isOwnProfile = userState.user.pubkey === profilePubkey
|
||||||
|
}
|
||||||
|
|
||||||
const userHexKey = userState.user.pubkey as string
|
const settled = await Promise.allSettled([
|
||||||
|
ndkContext.findMetadata(profilePubkey),
|
||||||
|
ndkContext.getMuteLists(userPubkey),
|
||||||
|
ndkContext.getNSFWList()
|
||||||
|
])
|
||||||
|
|
||||||
|
// Check the profile event result
|
||||||
|
const profileEventResult = settled[0]
|
||||||
|
if (profileEventResult.status === 'fulfilled' && profileEventResult.value) {
|
||||||
|
result.profile = profileEventResult.value
|
||||||
|
} else if (profileEventResult.status === 'rejected') {
|
||||||
|
log(
|
||||||
|
true,
|
||||||
|
LogType.Error,
|
||||||
|
'Failed to fetch profile.',
|
||||||
|
profileEventResult.reason
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the profile event result
|
||||||
|
const muteListResult = settled[1]
|
||||||
|
if (muteListResult.status === 'fulfilled' && muteListResult.value) {
|
||||||
|
result.muteLists = muteListResult.value
|
||||||
|
|
||||||
// Check if user has blocked this profile
|
// Check if user has blocked this profile
|
||||||
const muteListFilter: NDKFilter = {
|
result.isBlocked = result.muteLists.user.authors.includes(profilePubkey)
|
||||||
kinds: [NDKKind.MuteList],
|
} else if (muteListResult.status === 'rejected') {
|
||||||
authors: [userHexKey]
|
log(
|
||||||
}
|
true,
|
||||||
const muteList = await ndkContext.fetchEventFromUserRelays(
|
LogType.Error,
|
||||||
muteListFilter,
|
'Failed to fetch mutelist.',
|
||||||
userHexKey,
|
muteListResult.reason
|
||||||
UserRelaysType.Write
|
|
||||||
)
|
)
|
||||||
if (muteList) {
|
}
|
||||||
// get a list of tags
|
|
||||||
const tags = muteList.tags
|
|
||||||
const blocked =
|
|
||||||
tags.findIndex(
|
|
||||||
(item) => item[0] === 'p' && item[1] === profilePubkey
|
|
||||||
) !== -1
|
|
||||||
|
|
||||||
result.isBlocked = blocked
|
// Check the profile event result
|
||||||
}
|
const nsfwListResult = settled[2]
|
||||||
|
if (nsfwListResult.status === 'fulfilled' && nsfwListResult.value) {
|
||||||
|
result.nsfwList = nsfwListResult.value
|
||||||
|
} else if (nsfwListResult.status === 'rejected') {
|
||||||
|
log(
|
||||||
|
true,
|
||||||
|
LogType.Error,
|
||||||
|
'Failed to fetch mutelist.',
|
||||||
|
nsfwListResult.reason
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
@ -22,6 +22,8 @@ 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 { blogReportRouteAction } from 'pages/blog/reportAction'
|
||||||
|
|
||||||
export const appRoutes = {
|
export const appRoutes = {
|
||||||
index: '/',
|
index: '/',
|
||||||
@ -33,6 +35,8 @@ export const appRoutes = {
|
|||||||
about: '/about',
|
about: '/about',
|
||||||
blogs: '/blog',
|
blogs: '/blog',
|
||||||
blog: '/blog/:naddr',
|
blog: '/blog/:naddr',
|
||||||
|
blogEdit: '/blog/:naddr/edit',
|
||||||
|
blogReport_actionOnly: '/blog/:naddr/report',
|
||||||
submitMod: '/submit-mod',
|
submitMod: '/submit-mod',
|
||||||
editMod: '/edit-mod/:naddr',
|
editMod: '/edit-mod/:naddr',
|
||||||
write: '/write',
|
write: '/write',
|
||||||
@ -98,7 +102,18 @@ export const routerWithNdkContext = (context: NDKContextType) =>
|
|||||||
{
|
{
|
||||||
path: appRoutes.blog,
|
path: appRoutes.blog,
|
||||||
element: <BlogPage />,
|
element: <BlogPage />,
|
||||||
loader: blogRouteLoader(context)
|
loader: blogRouteLoader(context),
|
||||||
|
action: blogRouteAction(context)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: appRoutes.blogEdit,
|
||||||
|
element: <WritePage />,
|
||||||
|
loader: blogRouteLoader(context),
|
||||||
|
action: writeRouteAction(context)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: appRoutes.blogReport_actionOnly,
|
||||||
|
action: blogReportRouteAction(context)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: appRoutes.submitMod,
|
path: appRoutes.submitMod,
|
||||||
|
@ -31,4 +31,6 @@ export interface BlogCardDetails extends BlogDetails {
|
|||||||
export interface BlogPageLoaderResult {
|
export interface BlogPageLoaderResult {
|
||||||
blog: Partial<BlogDetails> | undefined
|
blog: Partial<BlogDetails> | undefined
|
||||||
latest: Partial<BlogDetails>[]
|
latest: Partial<BlogDetails>[]
|
||||||
|
isAddedToNSFW: boolean
|
||||||
|
isBlocked: boolean
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user