From f7f376468602c4d97ffc84685ec583b5f72b70a4 Mon Sep 17 00:00:00 2001 From: enes Date: Thu, 7 Nov 2024 17:33:59 +0100 Subject: [PATCH] feat(blog): moderation and more filtering --- src/pages/blog/action.ts | 264 +++++++++++++++++++++++++ src/pages/blog/index.tsx | 338 +++++++++++++++++++++++++-------- src/pages/blog/loader.ts | 68 ++++++- src/pages/blog/report.tsx | 92 +++++++++ src/pages/blog/reportAction.ts | 146 ++++++++++++++ src/pages/home.tsx | 2 +- src/pages/profile/index.tsx | 65 ++++++- src/pages/profile/loader.ts | 115 +++++++---- src/routes/index.tsx | 17 +- src/types/blog.ts | 2 + 10 files changed, 981 insertions(+), 128 deletions(-) create mode 100644 src/pages/blog/action.ts create mode 100644 src/pages/blog/report.tsx create mode 100644 src/pages/blog/reportAction.ts diff --git a/src/pages/blog/action.ts b/src/pages/blog/action.ts new file mode 100644 index 0000000..696fcdd --- /dev/null +++ b/src/pages/blog/action.ts @@ -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 + } diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx index 7fb5814..56d2b30 100644 --- a/src/pages/blog/index.tsx +++ b/src/pages/blog/index.tsx @@ -1,5 +1,10 @@ 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 Link from '@tiptap/extension-link' 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 { Interactions } from 'components/Internal/Interactions' 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 = () => { - 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 html = marked.parse(blog?.content || '', { async: false }) const sanitized = DOMPurify.sanitize(html) @@ -38,6 +53,42 @@ export const BlogPage = () => { [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 (
@@ -46,90 +97,223 @@ export const BlogPage = () => { {!blog ? ( ) : ( -
-
- {/* */} -
-
-
-
-

- {blog.title} -

-
-
- -
-
- {blog.nsfw && ( -
-

NSFW

-
- )} - {blog.tTags && - blog.tTags.map((t) => ( - - {t} + <> +
+ - - - {!!latest.length && ( -
-
-

- Latest blog posts -

-
- {latest.map((b) => ( - - ))} +
+
+
+

+ {blog.title} +

+
+
+ +
+
+ {blog.nsfw && ( +
+

NSFW

+
+ )} + {blog.tTags && + blog.tTags.map((t) => ( + + {t} + + ))}
- )} -
- + + {!!latest.length && ( +
+
+

+ Latest blog posts +

+
+ {latest.map((b) => ( + + ))} +
+
+
+ )} +
+ +
-
+ {navigation.state !== 'idle' && ( + + )} + {showReportPopUp && ( + setShowReportPopUp(false)} /> + )} + )} {!!blog?.author && }
diff --git a/src/pages/blog/loader.ts b/src/pages/blog/loader.ts index e74c68e..aadddb0 100644 --- a/src/pages/blog/loader.ts +++ b/src/pages/blog/loader.ts @@ -4,6 +4,7 @@ import { kinds, nip19 } from 'nostr-tools' import { LoaderFunctionArgs, redirect } from 'react-router-dom' import { toast } from 'react-toastify' import { appRoutes } from 'routes' +import { store } from 'store' import { BlogPageLoaderResult, FilterOptions, NSFWFilter } from 'types' import { DEFAULT_FILTER_OPTIONS, @@ -33,6 +34,10 @@ export const blogRouteLoader = log(true, LogType.Error, 'Unable to create filter from blog naddr.') 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 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([ ndkContext.fetchEvent(filter), - ndkContext.fetchEvents(latestModsFilter) + ndkContext.fetchEvents(latestModsFilter), + ndkContext.getMuteLists(userState?.user?.pubkey as string), + ndkContext.getNSFWList() ]) const result: BlogPageLoaderResult = { blog: undefined, - latest: [] + latest: [], + isAddedToNSFW: false, + isBlocked: false } // 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 } catch (error) { log( diff --git a/src/pages/blog/report.tsx b/src/pages/blog/report.tsx new file mode 100644 index 0000000..b3f6f14 --- /dev/null +++ b/src/pages/blog/report.tsx @@ -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 ( + <> +
+
+
+
+
+
+

Report Post

+
+
+ + + +
+
+
+ +
+ + {BLOG_REPORT_REASONS.map((r) => ( + + ))} +
+ +
+
+
+
+
+
+ + ) +} diff --git a/src/pages/blog/reportAction.ts b/src/pages/blog/reportAction.ts new file mode 100644 index 0000000..c39e593 --- /dev/null +++ b/src/pages/blog/reportAction.ts @@ -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 + } + } + } diff --git a/src/pages/home.tsx b/src/pages/home.tsx index 5532e22..4b6c7d2 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -381,7 +381,7 @@ const DisplayLatestBlogs = () => { } const results = await Promise.allSettled([ - fetchEvents(filter), + fetchEvents({ ...filter, kinds: [kinds.LongFormArticle] }), fetchEvents(latestFilter) ]) diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx index 6f1c305..584c34f 100644 --- a/src/pages/profile/index.tsx +++ b/src/pages/profile/index.tsx @@ -23,6 +23,7 @@ import { BlogCardDetails, FilterOptions, ModDetails, + ModeratedFilter, NSFWFilter, SortBy, UserRelaysType @@ -687,7 +688,8 @@ const ReportUserPopup = ({ } const ProfileTabBlogs = () => { - const { profile } = useLoaderData() as ProfilePageLoaderResult + const { profile, muteLists, nsfwList } = + useLoaderData() as ProfilePageLoaderResult const { fetchEvents } = useNDKContext() const [filterOptions] = useLocalStorage('filter-blog', DEFAULT_FILTER_OPTIONS) const [isLoading, setIsLoading] = useState(true) @@ -779,20 +781,67 @@ const ProfileTabBlogs = () => { } }, [blogfilter, blogs, fetchEvents, isLoading]) - const sortedBlogs = useMemo(() => { - const sorted = blogs || [] + 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 === 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) { - sorted.sort((a, b) => + _blogs.sort((a, b) => a.published_at && b.published_at ? b.published_at - a.published_at : 0 ) } 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 ) } - return sorted - }, [blogs, filterOptions.sort]) + return _blogs + }, [ + 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 ( <> @@ -801,7 +850,7 @@ const ProfileTabBlogs = () => {
- {sortedBlogs.map((b) => ( + {moderatedAndSortedBlogs.map((b) => ( ))}
diff --git a/src/pages/profile/loader.ts b/src/pages/profile/loader.ts index 3c80254..aecb15d 100644 --- a/src/pages/profile/loader.ts +++ b/src/pages/profile/loader.ts @@ -1,23 +1,25 @@ -import { NDKFilter, NDKKind } from '@nostr-dev-kit/ndk' import { NDKContextType } from 'contexts/NDKContext' import { nip19 } from 'nostr-tools' import { LoaderFunctionArgs, redirect } from 'react-router-dom' import { appRoutes, getProfilePageRoute } from 'routes' import { store } from 'store' -import { UserProfile, UserRelaysType } from 'types' +import { MuteLists, UserProfile } from 'types' import { log, LogType } from 'utils' export interface ProfilePageLoaderResult { profile: UserProfile isBlocked: boolean isOwnProfile: boolean + muteLists: { + admin: MuteLists + user: MuteLists + } + nsfwList: string[] } export const profileRouteLoader = (ndkContext: NDKContextType) => async ({ params }: LoaderFunctionArgs) => { - let profileRoute = appRoutes.home - // Try to decode nprofile parameter const { nprofile } = params let profilePubkey: string | undefined @@ -34,57 +36,94 @@ export const profileRouteLoader = // Get the current state const userState = store.getState().user - // Redirect route - // Redirect home if user is not logged in or profile naddr is missing - if (!profilePubkey && userState.auth && userState.user?.pubkey) { - // Redirect to user's profile is no profile is linked - const userHexKey = userState.user.pubkey as string - - if (userHexKey) { - profileRoute = getProfilePageRoute( - nip19.nprofileEncode({ - pubkey: userHexKey - }) - ) - } + // Check if current user is logged in + let userPubkey: string | undefined + if (userState.auth && userState.user?.pubkey) { + userPubkey = userState.user.pubkey as string } + // 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) + // Empty result const result: ProfilePageLoaderResult = { profile: {}, 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 if (userState.auth && userState.user?.pubkey) { 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 - const muteListFilter: NDKFilter = { - kinds: [NDKKind.MuteList], - authors: [userHexKey] - } - const muteList = await ndkContext.fetchEventFromUserRelays( - muteListFilter, - userHexKey, - UserRelaysType.Write + result.isBlocked = result.muteLists.user.authors.includes(profilePubkey) + } else if (muteListResult.status === 'rejected') { + log( + true, + LogType.Error, + 'Failed to fetch mutelist.', + muteListResult.reason ) - 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 diff --git a/src/routes/index.tsx b/src/routes/index.tsx index bab9915..8042e3f 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -22,6 +22,8 @@ import { BlogsPage } from 'pages/blogs' import { blogsRouteLoader } from 'pages/blogs/loader' import { BlogPage } from 'pages/blog' import { blogRouteLoader } from 'pages/blog/loader' +import { blogRouteAction } from 'pages/blog/action' +import { blogReportRouteAction } from 'pages/blog/reportAction' export const appRoutes = { index: '/', @@ -33,6 +35,8 @@ export const appRoutes = { about: '/about', blogs: '/blog', blog: '/blog/:naddr', + blogEdit: '/blog/:naddr/edit', + blogReport_actionOnly: '/blog/:naddr/report', submitMod: '/submit-mod', editMod: '/edit-mod/:naddr', write: '/write', @@ -98,7 +102,18 @@ export const routerWithNdkContext = (context: NDKContextType) => { path: appRoutes.blog, element: , - loader: blogRouteLoader(context) + loader: blogRouteLoader(context), + action: blogRouteAction(context) + }, + { + path: appRoutes.blogEdit, + element: , + loader: blogRouteLoader(context), + action: writeRouteAction(context) + }, + { + path: appRoutes.blogReport_actionOnly, + action: blogReportRouteAction(context) }, { path: appRoutes.submitMod, diff --git a/src/types/blog.ts b/src/types/blog.ts index 6e5b8b8..673656c 100644 --- a/src/types/blog.ts +++ b/src/types/blog.ts @@ -31,4 +31,6 @@ export interface BlogCardDetails extends BlogDetails { export interface BlogPageLoaderResult { blog: Partial | undefined latest: Partial[] + isAddedToNSFW: boolean + isBlocked: boolean }