import { NDKFilter, NDKKind } from '@nostr-dev-kit/ndk' import { LoadingSpinner } from 'components/LoadingSpinner' import { ModCard } from 'components/ModCard' import { ModFilter } from 'components/Filters/ModsFilter' import { Pagination } from 'components/Pagination' import { ProfileSection } from 'components/ProfileSection' import { Tabs } from 'components/Tabs' import { MOD_FILTER_LIMIT, PROFILE_BLOG_FILTER_LIMIT } from '../../constants' import { useAppSelector, useFilteredMods, useLocalStorage, useNDKContext } from 'hooks' import { kinds, UnsignedEvent } from 'nostr-tools' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Link, useLoaderData, useNavigation } from 'react-router-dom' import { toast } from 'react-toastify' import { appRoutes } from 'routes' import { BlogCardDetails, FilterOptions, ModDetails, ModeratedFilter, NSFWFilter, SortBy, UserRelaysType } from 'types' import { copyTextToClipboard, DEFAULT_FILTER_OPTIONS, extractBlogCardDetails, now, npubToHex, scrollIntoView, sendDMUsingRandomKey, signAndPublish } from 'utils' import { CheckboxField } from 'components/Inputs' import { ProfilePageLoaderResult } from './loader' import { BlogCard } from 'components/BlogCard' import { BlogsFilter } from 'components/Filters/BlogsFilter' export const ProfilePage = () => { const { profilePubkey, profile, isBlocked: _isBlocked, repostList, muteLists, nsfwList } = useLoaderData() as ProfilePageLoaderResult const scrollTargetRef = useRef(null) const { ndk, publish, fetchEventFromUserRelays, fetchMods } = useNDKContext() const userState = useAppSelector((state) => state.user) const [isLoading, setIsLoading] = useState(false) const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') const displayName = profile?.displayName || profile?.name || '[name not set up]' const [showReportPopUp, setShowReportPopUp] = useState(false) const isOwnProfile = userState.auth && userState.user?.pubkey && userState.user.pubkey === profilePubkey const [isBlocked, setIsBlocked] = useState(_isBlocked) const handleBlock = async () => { if (!profilePubkey) { toast.error(`Something went wrong. Unable to find reported user's pubkey`) return } let userHexKey: string setIsLoading(true) setLoadingSpinnerDesc('Getting user pubkey') if (userState.auth && userState.user?.pubkey) { userHexKey = userState.user.pubkey as string } else { userHexKey = (await window.nostr?.getPublicKey()) as string } if (!userHexKey) { toast.error('Could not get pubkey for updating mute list') setIsLoading(false) return } setLoadingSpinnerDesc(`Finding user's mute list`) // Define the event filter to search for the user's mute list events. // We look for events of a specific kind (Mutelist) authored by the given hexPubkey. const filter: NDKFilter = { kinds: [NDKKind.MuteList], authors: [userHexKey] } // Fetch the mute list event from the relays. This returns the event containing the user's mute list. const muteListEvent = await fetchEventFromUserRelays( filter, userHexKey, UserRelaysType.Write ) let unsignedEvent: UnsignedEvent if (muteListEvent) { // get a list of tags const tags = muteListEvent.tags const alreadyExists = tags.findIndex( (item) => item[0] === 'p' && item[1] === profilePubkey ) !== -1 if (alreadyExists) { setIsLoading(false) setIsBlocked(true) return toast.warn(`User is already in the mute list`) } tags.push(['p', profilePubkey]) unsignedEvent = { pubkey: muteListEvent.pubkey, kind: NDKKind.MuteList, content: muteListEvent.content, created_at: now(), tags: [...tags] } } else { unsignedEvent = { pubkey: userHexKey, kind: NDKKind.MuteList, content: '', created_at: now(), tags: [['p', profilePubkey]] } } setLoadingSpinnerDesc('Updating mute list event') const isUpdated = await signAndPublish(unsignedEvent, ndk, publish) if (isUpdated) { setIsBlocked(true) } setIsLoading(false) } const handleUnblock = async () => { if (!profilePubkey) { toast.error(`Something went wrong. Unable to find reported user's pubkey`) return } const userHexKey = userState.user?.pubkey as string const filter: NDKFilter = { kinds: [NDKKind.MuteList], authors: [userHexKey] } setIsLoading(true) setLoadingSpinnerDesc(`Finding user's mute list`) // Fetch the mute list event from the relays. This returns the event containing the user's mute list. const muteListEvent = await fetchEventFromUserRelays( filter, userHexKey, UserRelaysType.Write ) if (!muteListEvent) { toast.error(`Couldn't get user's mute list event from relays`) return } const tags = muteListEvent.tags const unsignedEvent: UnsignedEvent = { pubkey: muteListEvent.pubkey, kind: NDKKind.MuteList, content: muteListEvent.content, created_at: now(), tags: tags.filter((item) => item[0] !== 'p' || item[1] !== profilePubkey) } setLoadingSpinnerDesc('Updating mute list event') const isUpdated = await signAndPublish(unsignedEvent, ndk, publish) if (isUpdated) { setIsBlocked(false) } setIsLoading(false) } // Tabs const [tab, setTab] = useState(0) const [page, setPage] = useState(1) // Mods const [mods, setMods] = useState([]) const filterKey = 'filter-profile' const [filterOptions] = useLocalStorage(filterKey, { ...DEFAULT_FILTER_OPTIONS }) const handleNext = useCallback(() => { setIsLoading(true) const until = mods.length > 0 ? mods[mods.length - 1].published_at - 1 : undefined fetchMods({ source: filterOptions.source, until, author: profilePubkey }) .then((res) => { setMods(res) setPage((prev) => prev + 1) scrollIntoView(scrollTargetRef.current) }) .finally(() => { setIsLoading(false) }) }, [mods, fetchMods, filterOptions.source, profilePubkey]) const handlePrev = useCallback(() => { setIsLoading(true) const since = mods.length > 0 ? mods[0].published_at + 1 : undefined fetchMods({ source: filterOptions.source, since, author: profilePubkey }) .then((res) => { setMods(res) setPage((prev) => prev - 1) scrollIntoView(scrollTargetRef.current) }) .finally(() => { setIsLoading(false) }) }, [mods, fetchMods, filterOptions.source, profilePubkey]) useEffect(() => { setIsLoading(true) switch (tab) { case 0: setLoadingSpinnerDesc('Fetching mods..') fetchMods({ source: filterOptions.source, author: profilePubkey }) .then((res) => { setMods(res) }) .finally(() => { setIsLoading(false) }) break default: setIsLoading(false) break } }, [filterOptions.source, tab, fetchMods, profilePubkey]) const filteredModList = useFilteredMods( mods, userState, filterOptions, nsfwList, muteLists, repostList, profilePubkey ) return (
{/* Tabs Content */} {tab === 0 && ( <>
{filteredModList.map((mod) => ( ))}
)} {tab === 1 && } {tab === 2 && <>WIP}
{showReportPopUp && ( setShowReportPopUp(false)} /> )}
) } type ReportUserPopupProps = { reportedPubkey: string handleClose: () => void } const USER_REPORT_REASONS = [ { label: `User posts actual CP`, key: 'user_actuallyCP' }, { label: `User is a spammer`, key: 'user_spam' }, { label: `User is a scammer`, key: 'user_scam' }, { label: `User posts malware`, key: 'user_malware' }, { label: `User posts non-mods`, key: 'user_notAGameMod' }, { label: `User doesn't tag NSFW`, key: 'user_wasntTaggedNSFW' }, { label: `Other (user)`, key: 'user_otherReason' } ] const ReportUserPopup = ({ reportedPubkey, handleClose }: ReportUserPopupProps) => { const { ndk, fetchEventFromUserRelays, publish } = useNDKContext() const userState = useAppSelector((state) => state.user) const [selectedOptions, setSelectedOptions] = useState( USER_REPORT_REASONS.reduce((acc: { [key: string]: boolean }, cur) => { acc[cur.key] = false return acc }, {}) ) const [isLoading, setIsLoading] = useState(false) const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') const handleCheckboxChange = (option: keyof typeof selectedOptions) => { setSelectedOptions((prevState) => ({ ...prevState, [option]: !prevState[option] })) } const handleSubmit = async () => { const selectedOptionsCount = Object.values(selectedOptions).filter( (isSelected) => isSelected ).length if (selectedOptionsCount === 0) { toast.error('At least one option should be checked!') return } setIsLoading(true) setLoadingSpinnerDesc('Getting user pubkey') let userHexKey: string if (userState.auth && userState.user?.pubkey) { userHexKey = userState.user.pubkey as string } else { userHexKey = (await window.nostr?.getPublicKey()) as string } if (!userHexKey) { toast.error('Could not get pubkey for reporting user!') setIsLoading(false) return } const reportingNpub = import.meta.env.VITE_REPORTING_NPUB const reportingPubkey = npubToHex(reportingNpub) if (reportingPubkey === userHexKey) { setLoadingSpinnerDesc(`Finding user's mute list`) // Define the event filter to search for the user's mute list events. // We look for events of a specific kind (Mutelist) authored by the given hexPubkey. const filter: NDKFilter = { kinds: [NDKKind.MuteList], authors: [userHexKey] } // Fetch the mute list event from the relays. This returns the event containing the user's mute list. const muteListEvent = await fetchEventFromUserRelays( filter, userHexKey, UserRelaysType.Write ) let unsignedEvent: UnsignedEvent if (muteListEvent) { // get a list of tags const tags = muteListEvent.tags const alreadyExists = tags.findIndex( (item) => item[0] === 'p' && item[1] === reportedPubkey ) !== -1 if (alreadyExists) { setIsLoading(false) return toast.warn( `Reporter user's pubkey is already in the mute list` ) } tags.push(['p', reportedPubkey]) unsignedEvent = { pubkey: muteListEvent.pubkey, kind: NDKKind.MuteList, content: muteListEvent.content, created_at: now(), tags: [...tags] } } else { unsignedEvent = { pubkey: userHexKey, kind: NDKKind.MuteList, content: '', created_at: now(), tags: [['p', reportedPubkey]] } } setLoadingSpinnerDesc('Updating mute list event') const isUpdated = await signAndPublish(unsignedEvent, ndk, publish) if (isUpdated) handleClose() } else { const href = window.location.href let message = `I'd like to report ${href} due to following reasons:\n` Object.entries(selectedOptions).forEach(([key, value]) => { if (value) { message += `* ${key}\n` } }) setLoadingSpinnerDesc('Sending report') const isSent = await sendDMUsingRandomKey( message, reportingPubkey!, ndk, publish ) if (isSent) handleClose() } setIsLoading(false) } return ( <> {isLoading && }

Report Post

{USER_REPORT_REASONS.map((r) => ( handleCheckboxChange(r.key)} /> ))}
) } const ProfileTabBlogs = () => { const { profilePubkey, muteLists, nsfwList } = useLoaderData() as ProfilePageLoaderResult const navigation = useNavigation() const { fetchEvents } = useNDKContext() const [filterOptions] = useLocalStorage('filter-blog', DEFAULT_FILTER_OPTIONS) const [isLoading, setIsLoading] = useState(true) const blogfilter: NDKFilter = useMemo(() => { const filter: NDKFilter = { authors: [profilePubkey], kinds: [kinds.LongFormArticle] } const host = window.location.host if (filterOptions.source === host) { filter['#r'] = [host] } if (filterOptions.nsfw === NSFWFilter.Only_NSFW) { filter['#L'] = ['content-warning'] } return filter }, [filterOptions.nsfw, filterOptions.source, profilePubkey]) const [page, setPage] = useState(1) const [hasMore, setHasMore] = useState(false) const [blogs, setBlogs] = useState[]>([]) useEffect(() => { if (profilePubkey) { // Initial blog fetch, go beyond limit to check for next const filter: NDKFilter = { ...blogfilter, limit: PROFILE_BLOG_FILTER_LIMIT + 1 } fetchEvents(filter) .then((events) => { setBlogs(events.map(extractBlogCardDetails).filter((b) => b.naddr)) setHasMore(events.length > PROFILE_BLOG_FILTER_LIMIT) }) .finally(() => { setIsLoading(false) }) } }, [blogfilter, fetchEvents, profilePubkey]) const handleNext = useCallback(() => { if (isLoading) return const last = blogs.length > 0 ? blogs[blogs.length - 1] : undefined if (last?.published_at) { const until = last?.published_at - 1 const nextFilter = { ...blogfilter, limit: PROFILE_BLOG_FILTER_LIMIT + 1, until } setIsLoading(true) fetchEvents(nextFilter) .then((events) => { const nextBlogs = events.map(extractBlogCardDetails) setHasMore(nextBlogs.length > PROFILE_BLOG_FILTER_LIMIT) setPage((prev) => prev + 1) setBlogs( nextBlogs.slice(0, PROFILE_BLOG_FILTER_LIMIT).filter((b) => b.naddr) ) }) .finally(() => setIsLoading(false)) } }, [blogfilter, blogs, fetchEvents, isLoading]) const handlePrev = useCallback(() => { if (isLoading) return const first = blogs.length > 0 ? blogs[0] : undefined if (first?.published_at) { const since = first.published_at + 1 const prevFilter = { ...blogfilter, limit: PROFILE_BLOG_FILTER_LIMIT, since } setIsLoading(true) fetchEvents(prevFilter) .then((events) => { setHasMore(true) setPage((prev) => prev - 1) setBlogs(events.map(extractBlogCardDetails).filter((b) => b.naddr)) }) .finally(() => setIsLoading(false)) } }, [blogfilter, blogs, fetchEvents, isLoading]) const userState = useAppSelector((state) => state.user) 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 === profilePubkey 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 }) } // Filter nsfw (Hide_NSFW option) _blogs = _blogs.filter( (b) => !(b.nsfw && filterOptions.nsfw === NSFWFilter.Hide_NSFW) ) // 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) { _blogs.sort((a, b) => a.published_at && b.published_at ? b.published_at - a.published_at : 0 ) } else if (filterOptions.sort === SortBy.Oldest) { _blogs.sort((a, b) => a.published_at && b.published_at ? a.published_at - b.published_at : 0 ) } return _blogs }, [ blogs, filterOptions.moderated, filterOptions.nsfw, filterOptions.sort, muteLists.admin.authors, muteLists.admin.replaceableEvents, muteLists.user.authors, muteLists.user.replaceableEvents, nsfwList, profilePubkey, userState.user?.npub, userState.user?.pubkey ]) return ( <> {(isLoading || navigation.state !== 'idle') && ( )}
{moderatedAndSortedBlogs.map((b) => ( ))}
{!(page === 1 && !hasMore) && ( )} ) }