From c9ceed6c0f99e328d3127d89337abcdf3d3647b0 Mon Sep 17 00:00:00 2001 From: freakoverse Date: Thu, 21 Nov 2024 20:45:27 +0000 Subject: [PATCH 01/14] adjustments to modify the look of 'view' button on mod post body (now 'read full') --- src/pages/mod/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pages/mod/index.tsx b/src/pages/mod/index.tsx index dc34ca6..98a0ae9 100644 --- a/src/pages/mod/index.tsx +++ b/src/pages/mod/index.tsx @@ -926,7 +926,9 @@ const Body = ({ >
-

View

+
+

Read Full

+
-- 2.34.1 From f05f0dc1ea7fad8d7d1c717bdcb799d64014e11a Mon Sep 17 00:00:00 2001 From: freakoverse Date: Thu, 21 Nov 2024 20:47:13 +0000 Subject: [PATCH 02/14] new css and adjusted current ones --- src/styles/post.css | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/styles/post.css b/src/styles/post.css index 1792296..67bada2 100644 --- a/src/styles/post.css +++ b/src/styles/post.css @@ -163,11 +163,28 @@ flex-direction: column; justify-content: end; align-items: center; - padding: 15px; color: rgba(255,255,255,0.75); font-weight: bold; cursor: pointer; box-shadow: inset 0 0 8px 0 rgb(0,0,0,0.1); + overflow: hidden; + margin-bottom: -1px; +} + +.IBMSMSMBSSPostBodyHideText { + width: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background: rgb(55 55 55); +} + +.IBMSMSMBSSPostBodyHideText > * { + width: 100%; + text-align: center; + margin: 0; + padding: 10px; } .IBMSMSMBSSModFor { -- 2.34.1 From 61a94e53588e44152ff202be3dd7ba7db1741e4e Mon Sep 17 00:00:00 2001 From: freakoverse Date: Thu, 21 Nov 2024 20:51:21 +0000 Subject: [PATCH 03/14] Update src/styles/post.css --- src/styles/post.css | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/styles/post.css b/src/styles/post.css index 67bada2..0722072 100644 --- a/src/styles/post.css +++ b/src/styles/post.css @@ -180,13 +180,6 @@ background: rgb(55 55 55); } -.IBMSMSMBSSPostBodyHideText > * { - width: 100%; - text-align: center; - margin: 0; - padding: 10px; -} - .IBMSMSMBSSModFor { width: 100%; border-radius: 10px; @@ -290,3 +283,9 @@ text-align: center; } +.IBMSMSMBSSPostBodyHideText > * { + width: 100%; + text-align: center; + margin: 0; + padding: 10px; +} \ No newline at end of file -- 2.34.1 From f29a2634fdc2a2aa69352c4ed1e4b464d9dd7db0 Mon Sep 17 00:00:00 2001 From: freakoverse Date: Thu, 21 Nov 2024 20:58:39 +0000 Subject: [PATCH 04/14] Update src/styles/post.css --- src/styles/post.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/styles/post.css b/src/styles/post.css index 0722072..d3049ca 100644 --- a/src/styles/post.css +++ b/src/styles/post.css @@ -252,7 +252,7 @@ padding: 2px; } -.IBMSMSMBSSPostBody > div > div > p { +.IBMSMSMBSSPostBody > div:first-child > div > p { margin-bottom: 10px; } -- 2.34.1 From 38bd02968787644ec6dceff6bf246adda52528fe Mon Sep 17 00:00:00 2001 From: freakoverse Date: Thu, 21 Nov 2024 21:03:28 +0000 Subject: [PATCH 05/14] Update src/styles/post.css --- src/styles/post.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/styles/post.css b/src/styles/post.css index d3049ca..7e66e6a 100644 --- a/src/styles/post.css +++ b/src/styles/post.css @@ -165,7 +165,6 @@ align-items: center; color: rgba(255,255,255,0.75); font-weight: bold; - cursor: pointer; box-shadow: inset 0 0 8px 0 rgb(0,0,0,0.1); overflow: hidden; margin-bottom: -1px; @@ -178,6 +177,7 @@ justify-content: center; align-items: center; background: rgb(55 55 55); + cursor: pointer; } .IBMSMSMBSSModFor { -- 2.34.1 From c38d14a633028f97dc33be16159972489e83bbe4 Mon Sep 17 00:00:00 2001 From: freakoverse Date: Fri, 22 Nov 2024 12:08:34 +0000 Subject: [PATCH 06/14] Update src/pages/mod/index.tsx --- src/pages/mod/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/mod/index.tsx b/src/pages/mod/index.tsx index 98a0ae9..bb36a1a 100644 --- a/src/pages/mod/index.tsx +++ b/src/pages/mod/index.tsx @@ -922,7 +922,7 @@ const Body = ({
-- 2.34.1 From 3d5d59a64df620105e63e3939a768a3dd06ebcc9 Mon Sep 17 00:00:00 2001 From: freakoverse Date: Tue, 26 Nov 2024 16:01:02 +0000 Subject: [PATCH 07/14] Update src/assets/games/Games_SteamManual.csv --- src/assets/games/Games_SteamManual.csv | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/assets/games/Games_SteamManual.csv b/src/assets/games/Games_SteamManual.csv index d38ccaf..63a67dd 100644 --- a/src/assets/games/Games_SteamManual.csv +++ b/src/assets/games/Games_SteamManual.csv @@ -1,2 +1,3 @@ Game Name,16 by 9 image,Boxart image -Marvel's Spider-Man 2,,https://image.nostr.build/b5ef5ef8bd99daab73148145b024a1e6177160fd287ce829f82ba46e821490b6.jpg \ No newline at end of file +Marvel's Spider-Man 2,,https://image.nostr.build/b5ef5ef8bd99daab73148145b024a1e6177160fd287ce829f82ba46e821490b6.jpg +S.T.A.L.K.E.R. 2: Heart of Chornobyl,,https://image.nostr.build/f5b61071bebcc8deccfd71362696fb708649b9c528bec1c6964262ded4157843.jpg \ No newline at end of file -- 2.34.1 From c55dc033820f3f72a3e6d8e5c80077437751f78d Mon Sep 17 00:00:00 2001 From: enes Date: Wed, 27 Nov 2024 12:33:09 +0100 Subject: [PATCH 08/14] refactor: mod page, add generic report popup, repost option --- .../reportAction.ts => actions/report.ts} | 38 +- src/components/LoadingSpinner/index.tsx | 12 + .../report.tsx => components/ReportPopup.tsx} | 26 +- src/pages/blog/index.tsx | 25 +- src/pages/mod/action.ts | 233 +++++ src/pages/mod/index.tsx | 908 +++++------------- src/pages/mod/loader.ts | 228 +++++ src/pages/write/{action.tsx => action.ts} | 0 src/routes/index.tsx | 30 +- src/types/mod.ts | 10 + src/types/report.ts | 4 + src/utils/curationSets.ts | 159 +++ src/utils/index.ts | 1 + src/utils/mod.ts | 2 + 14 files changed, 951 insertions(+), 725 deletions(-) rename src/{pages/blog/reportAction.ts => actions/report.ts} (80%) rename src/{pages/blog/report.tsx => components/ReportPopup.tsx} (84%) create mode 100644 src/pages/mod/action.ts create mode 100644 src/pages/mod/loader.ts rename src/pages/write/{action.tsx => action.ts} (100%) create mode 100644 src/types/report.ts create mode 100644 src/utils/curationSets.ts diff --git a/src/pages/blog/reportAction.ts b/src/actions/report.ts similarity index 80% rename from src/pages/blog/reportAction.ts rename to src/actions/report.ts index c39e593..d36faa0 100644 --- a/src/pages/blog/reportAction.ts +++ b/src/actions/report.ts @@ -15,9 +15,14 @@ import { signAndPublish } from 'utils' -export const blogReportRouteAction = +export const reportRouteAction = (ndkContext: NDKContextType) => async ({ params, request }: ActionFunctionArgs) => { + // Check which post type is reported + const url = new URL(request.url) + const isModReport = url.pathname.startsWith('/mod/') + const isBlogReport = url.pathname.startsWith('/blog/') + const title = isModReport ? 'Mod' : isBlogReport ? 'Blog' : 'Post' const requestData = await request.formData() const { naddr } = params if (!naddr) { @@ -30,7 +35,12 @@ export const blogReportRouteAction = try { const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`) const { identifier, kind, pubkey } = decoded.data + aTag = `${kind}:${pubkey}:${identifier}` + + if (isModReport) { + aTag = identifier + } } catch (error) { log(true, LogType.Error, 'Failed to decode naddr') return false @@ -82,7 +92,7 @@ export const blogReportRouteAction = 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`) + toast.warn(`${title} reference is already in user's mute list`) return false } tags.push(['a', aTag]) @@ -109,10 +119,12 @@ export const blogReportRouteAction = log( true, LogType.Error, - 'Could not get pubkey for reporting blog!', + `Could not get pubkey for reporting ${title.toLowerCase()}!`, error ) - toast.error('Could not get pubkey for reporting blog!') + toast.error( + `Could not get pubkey for reporting ${title.toLowerCase()}!` + ) return false } @@ -122,7 +134,7 @@ export const blogReportRouteAction = ndkContext.publish ) return { isSent: isUpdated } - } else { + } else if (reportingPubkey) { const href = window.location.href let message = `I'd like to report ${href} due to following reasons:\n` Object.entries(formSubmit).forEach(([key, value]) => { @@ -133,14 +145,26 @@ export const blogReportRouteAction = try { const isSent = await sendDMUsingRandomKey( message, - reportingPubkey!, + reportingPubkey, ndkContext.ndk, ndkContext.publish ) return { isSent: isSent } } catch (error) { - log(true, LogType.Error, 'Failed to send a blog report', error) + log( + true, + LogType.Error, + `Failed to send a ${title.toLowerCase()} report`, + error + ) return false } + } else { + log( + true, + LogType.Error, + `Failed to send a ${title.toLowerCase()} report: VITE_REPORTING_NPUB missing` + ) + return false } } diff --git a/src/components/LoadingSpinner/index.tsx b/src/components/LoadingSpinner/index.tsx index 9f22262..f1f79af 100644 --- a/src/components/LoadingSpinner/index.tsx +++ b/src/components/LoadingSpinner/index.tsx @@ -1,3 +1,4 @@ +import { useNavigation } from 'react-router-dom' import styles from '../../styles/loadingSpinner.module.scss' interface Props { @@ -16,3 +17,14 @@ export const LoadingSpinner = (props: Props) => {
) } + +export const RouterLoadingSpinner = () => { + const navigation = useNavigation() + + if (navigation.state === 'idle') return null + + const desc = + navigation.state.charAt(0).toUpperCase() + navigation.state.slice(1) + + return +} diff --git a/src/pages/blog/report.tsx b/src/components/ReportPopup.tsx similarity index 84% rename from src/pages/blog/report.tsx rename to src/components/ReportPopup.tsx index bbbd880..d31e4b3 100644 --- a/src/pages/blog/report.tsx +++ b/src/components/ReportPopup.tsx @@ -1,22 +1,23 @@ import { useFetcher } from 'react-router-dom' import { CheckboxFieldUncontrolled } from 'components/Inputs' import { useEffect } from 'react' +import { ReportReason } from 'types/report' +import { LoadingSpinner } from './LoadingSpinner' type ReportPopupProps = { + openedAt: number + reasons: ReportReason[] 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() +export const ReportPopup = ({ + openedAt, + reasons, + handleClose +}: ReportPopupProps) => { + // Use openedAt to allow for multiple reports + // by default, fetcher will remember the data + const fetcher = useFetcher({ key: openedAt.toString() }) // Close automatically if action succeeds useEffect(() => { @@ -30,6 +31,7 @@ export const ReportPopup = ({ handleClose }: ReportPopupProps) => { return ( <> + {fetcher.state !== 'idle' && }
@@ -64,7 +66,7 @@ export const ReportPopup = ({ handleClose }: ReportPopupProps) => { > Why are you reporting this? - {BLOG_REPORT_REASONS.map((r) => ( + {reasons.map((r) => ( { const { blog, latest, isAddedToNSFW, isBlocked } = @@ -53,8 +62,8 @@ export const BlogPage = () => { [sanitized] ) - const [showReportPopUp, setShowReportPopUp] = useState(false) - useBodyScrollDisable(showReportPopUp) + const [showReportPopUp, setShowReportPopUp] = useState() + useBodyScrollDisable(!!showReportPopUp) const submit = useSubmit() const handleBlock = () => { @@ -190,7 +199,7 @@ export const BlogPage = () => { setShowReportPopUp(true)} + onClick={() => setShowReportPopUp(Date.now())} > { {navigation.state !== 'idle' && ( )} - {showReportPopUp && ( - setShowReportPopUp(false)} /> + {!!showReportPopUp && ( + setShowReportPopUp(undefined)} + /> )} )} diff --git a/src/pages/mod/action.ts b/src/pages/mod/action.ts new file mode 100644 index 0000000..5f7f477 --- /dev/null +++ b/src/pages/mod/action.ts @@ -0,0 +1,233 @@ +import { NDKFilter } from '@nostr-dev-kit/ndk' +import { NDKContextType } from 'contexts/NDKContext' +import { kinds, nip19, UnsignedEvent } from 'nostr-tools' +import { ActionFunctionArgs } from 'react-router-dom' +import { toast } from 'react-toastify' +import { store } from 'store' +import { UserRelaysType } from 'types' +import { + addToCurationSet, + CurationSetIdentifiers, + log, + LogType, + now, + removeFromCurationSet, + signAndPublish +} from 'utils' + +export const modRouteAction = + (ndkContext: NDKContextType) => + async ({ params, request }: ActionFunctionArgs) => { + const { naddr } = params + if (!naddr) { + log(true, LogType.Error, 'Required naddr.') + return null + } + + // Decode author from naddr + let aTag: string | undefined + try { + const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`) + + // We encode mods naddr identifier as a whole aTag + const { identifier } = decoded.data + aTag = identifier + } catch (error) { + log(true, LogType.Error, 'Failed to decode naddr') + return null + } + + if (!aTag) { + log(true, LogType.Error, 'Missing #a Tag') + return null + } + + const userState = store.getState().user + let hexPubkey: string + if (userState.auth && userState.user?.pubkey) { + hexPubkey = userState.user.pubkey as string + } else { + hexPubkey = (await window.nostr?.getPublicKey()) as string + } + + if (!hexPubkey) { + toast.error('Failed to get the pubkey') + return null + } + + const isAdmin = + userState.user?.npub && + userState.user.npub === import.meta.env.VITE_REPORTING_NPUB + + const handleBlock = async () => { + // Define the event filter to search for the user's mute list events. + // We look for events of a specific kind (Mutelist) authored by the given hexPubkey. + const filter: NDKFilter = { + kinds: [kinds.Mutelist], + authors: [hexPubkey] + } + + // Fetch the mute list event from the relays. This returns the event containing the user's mute list. + const muteListEvent = await ndkContext.fetchEventFromUserRelays( + filter, + hexPubkey, + UserRelaysType.Write + ) + + let unsignedEvent: UnsignedEvent + if (muteListEvent) { + // get a list of tags + const tags = muteListEvent.tags + const alreadyExists = + tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== -1 + + if (alreadyExists) { + toast.warn(`Mod reference is already in user's mute list`) + return null + } + + tags.push(['a', aTag]) + + unsignedEvent = { + pubkey: muteListEvent.pubkey, + kind: kinds.Mutelist, + content: muteListEvent.content, + created_at: now(), + tags: [...tags] + } + } else { + unsignedEvent = { + pubkey: hexPubkey, + kind: kinds.Mutelist, + content: '', + created_at: now(), + tags: [['a', aTag]] + } + } + + const isUpdated = await signAndPublish( + unsignedEvent, + ndkContext.ndk, + ndkContext.publish + ) + + if (!isUpdated) { + toast.error("Failed to update user's mute list") + } + return null + } + + const handleUnblock = async () => { + const filter: NDKFilter = { + kinds: [kinds.Mutelist], + authors: [hexPubkey] + } + const muteListEvent = await ndkContext.fetchEventFromUserRelays( + filter, + hexPubkey, + UserRelaysType.Write + ) + if (!muteListEvent) { + toast.error(`Couldn't get user's mute list event from relays`) + return null + } + + const tags = muteListEvent.tags + const unsignedEvent: UnsignedEvent = { + pubkey: muteListEvent.pubkey, + kind: kinds.Mutelist, + content: muteListEvent.content, + created_at: now(), + tags: tags.filter((item) => item[0] !== 'a' || item[1] !== aTag) + } + + const isUpdated = await signAndPublish( + unsignedEvent, + ndkContext.ndk, + ndkContext.publish + ) + if (!isUpdated) { + toast.error("Failed to update user's mute list") + } + return null + } + const handleAddNSFW = async () => { + const success = await addToCurationSet( + { + dTag: CurationSetIdentifiers.NSFW, + pubkey: hexPubkey, + ndkContext + }, + aTag + ) + log(true, LogType.Info, `ModAction - NSFW - Add - ${success}`) + return null + } + const handleRemoveNSFW = async () => { + const success = await removeFromCurationSet( + { + dTag: CurationSetIdentifiers.NSFW, + pubkey: hexPubkey, + ndkContext + }, + aTag + ) + log(true, LogType.Info, `ModAction - Repost - Remove - ${success}`) + return null + } + + const handleRepost = async () => { + const success = await addToCurationSet( + { + dTag: CurationSetIdentifiers.Repost, + pubkey: hexPubkey, + ndkContext + }, + aTag + ) + log(true, LogType.Info, `ModAction - NSFW - Add - ${success}`) + return null + } + + const handleRemoveRepost = async () => { + const success = await removeFromCurationSet( + { + dTag: CurationSetIdentifiers.Repost, + pubkey: hexPubkey, + ndkContext + }, + aTag + ) + log(true, LogType.Info, `ModAction - Repost - Remove - ${success}`) + return null + } + + const requestData = (await request.json()) as { + intent: 'nsfw' | 'block' | 'repost' + value: boolean + } + + switch (requestData.intent) { + case 'block': + await (requestData.value ? handleBlock() : handleUnblock()) + break + + case 'repost': + await (requestData.value ? handleRepost() : handleRemoveRepost()) + break + + case 'nsfw': + if (!isAdmin) { + log(true, LogType.Error, 'Unable to update NSFW list. No permission') + return null + } + await (requestData.value ? handleAddNSFW() : handleRemoveNSFW()) + break + + default: + log(true, LogType.Error, 'Missing intent for mod action') + break + } + + return null + } diff --git a/src/pages/mod/index.tsx b/src/pages/mod/index.tsx index dc34ca6..e1746cf 100644 --- a/src/pages/mod/index.tsx +++ b/src/pages/mod/index.tsx @@ -1,23 +1,21 @@ -import { NDKFilter, NDKKind } from '@nostr-dev-kit/ndk' import Link from '@tiptap/extension-link' import { EditorContent, useEditor } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import FsLightbox from 'fslightbox-react' -import { kinds, nip19, UnsignedEvent } from 'nostr-tools' -import { useEffect, useRef, useState } from 'react' -import { Link as ReactRouterLink, useParams } from 'react-router-dom' +import { nip19 } from 'nostr-tools' +import { useRef, useState } from 'react' +import { + Link as ReactRouterLink, + useLoaderData, + useNavigation, + useParams, + useSubmit +} from 'react-router-dom' import { toast } from 'react-toastify' import { BlogCard } from '../../components/BlogCard' -import { LoadingSpinner } from '../../components/LoadingSpinner' import { ProfileSection } from '../../components/ProfileSection' -import { - useAppSelector, - useBodyScrollDisable, - useDidMount, - useNDKContext, - useNSFWList -} from '../../hooks' -import { getGamePageRoute, getModsEditPageRoute } from '../../routes' +import { useAppSelector, useBodyScrollDisable } from '../../hooks' +import { getGamePageRoute } from '../../routes' import '../../styles/comments.css' import '../../styles/downloads.css' import '../../styles/innerPage.css' @@ -28,77 +26,46 @@ import '../../styles/styles.css' import '../../styles/tabs.css' import '../../styles/tags.css' import '../../styles/write.css' -import { - BlogCardDetails, - DownloadUrl, - ModDetails, - UserRelaysType -} from '../../types' +import { DownloadUrl, ModPageLoaderResult } from '../../types' import { copyTextToClipboard, downloadFile, - extractModData, - getFilenameFromUrl, - log, - LogType, - now, - npubToHex, - sendDMUsingRandomKey, - signAndPublish + getFilenameFromUrl } from '../../utils' import { Comments } from '../../components/comment' -import { CheckboxField } from 'components/Inputs' import { PublishDetails } from 'components/Internal/PublishDetails' import { Interactions } from 'components/Internal/Interactions' -import { extractBlogCardDetails } from 'utils/blog' +import { ReportPopup } from 'components/ReportPopup' +import { Spinner } from 'components/Spinner' +import { RouterLoadingSpinner } from 'components/LoadingSpinner' + +const MOD_REPORT_REASONS = [ + { label: 'Actually CP', key: 'actuallyCP' }, + { label: 'Spam', key: 'spam' }, + { label: 'Scam', key: 'scam' }, + { label: 'Not a game mod', key: 'notAGameMod' }, + { label: 'Stolen game mod', key: 'stolenGameMod' }, + { label: `Repost of a game mod`, key: 'repost' }, + { label: `Wasn't tagged NSFW`, key: 'wasntTaggedNSFW' }, + { label: 'Other reason', key: 'otherReason' } +] export const ModPage = () => { + const { mod } = useLoaderData() as ModPageLoaderResult + + // We can get author right away from naddr, no need to wait for mod data const { naddr } = useParams() - const { fetchEvent } = useNDKContext() - const [mod, setMod] = useState() - const [isFetching, setIsFetching] = useState(true) - const [commentCount, setCommentCount] = useState(0) - - // Make sure to mark non-nsfw mods as NSFW if found in nsfwList - const nsfwList = useNSFWList() - const isMissingNsfwTag = - !mod?.nsfw && mod?.aTag && nsfwList && nsfwList.includes(mod.aTag) - const modData = isMissingNsfwTag - ? ({ ...mod, nsfw: true } as ModDetails) - : mod - - useDidMount(async () => { - if (naddr) { + let author = mod?.author + if (naddr && !author) { + try { const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`) - const { identifier, kind, pubkey } = decoded.data - - const filter: NDKFilter = { - '#a': [identifier], - authors: [pubkey], - kinds: [kind] - } - - fetchEvent(filter) - .then((event) => { - if (event) { - const extracted = extractModData(event) - setMod(extracted) - } - }) - .catch((err) => { - log( - true, - LogType.Error, - 'An error occurred in fetching mod details from relays', - err - ) - toast.error('An error occurred in fetching mod details from relays') - }) - .finally(() => { - setIsFetching(false) - }) + author = decoded.data.pubkey + } catch (error) { + // Silently ignore - we will get author eventually from mods } - }) + } + + const [commentCount, setCommentCount] = useState(0) const oldDownloadListRef = useRef(null) @@ -119,404 +86,168 @@ export const ModPage = () => { } } - if (isFetching) - return - - if (!modData) return null - return ( -
-
-
-
-
-
- - - - -
-
-
-

Mod Download

- {modData.downloadUrls.length > 0 && ( -
- + <> + +
+
+
+
+
+ {mod ? ( + <> +
+ + + +
- )} - {modData.downloadUrls.length > 1 && ( - <> -
- +
+
+

+ Mod Download +

+ {mod.downloadUrls.length > 0 && ( +
+ +
+ )} + {mod.downloadUrls.length > 1 && ( + <> +
+ +
+
+ {mod.downloadUrls + .slice(1) + .map((download, index) => ( + + ))} +
+ + )}
-
- {modData.downloadUrls - .slice(1) - .map((download, index) => ( - - ))} -
- - )} -
-
- -
- +
+ +
+ +
+ + ) : ( + + )}
+ {typeof author !== 'undefined' && ( + + )}
-
-
+ ) } -type GameProps = { - naddr: string - game: string - author: string - aTag: string -} - -const Game = ({ naddr, game, author, aTag }: GameProps) => { - const { ndk, fetchEventFromUserRelays, publish } = useNDKContext() - +const Game = () => { + const navigation = useNavigation() + const { mod, isAddedToNSFW, isBlocked, isRepost } = + useLoaderData() as ModPageLoaderResult const userState = useAppSelector((state) => state.user) - const [isLoading, setIsLoading] = useState(false) - const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') - const [showReportPopUp, setShowReportPopUp] = useState(false) - const [isBlocked, setIsBlocked] = useState(false) - const [isAddedToNSFW, setIsAddedToNSFW] = useState(false) + const [showReportPopUp, setShowReportPopUp] = useState() - useBodyScrollDisable(showReportPopUp) + useBodyScrollDisable(!!showReportPopUp) - useEffect(() => { - if (userState.auth && userState.user?.pubkey) { - const pubkey = userState.user.pubkey as string - - const muteListFilter: NDKFilter = { - kinds: [NDKKind.MuteList], - authors: [pubkey] - } - - fetchEventFromUserRelays( - muteListFilter, - pubkey, - UserRelaysType.Write - ).then((event) => { - if (event) { - // get a list of tags - const tags = event.tags - const blocked = - tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== -1 - - setIsBlocked(blocked) + const submit = useSubmit() + const handleBlock = () => { + if (navigation.state === 'idle') { + submit( + { + intent: 'block', + value: !isBlocked + }, + { + method: 'post', + encType: 'application/json' } - }) + ) + } + } - if ( - userState.user.npub && - userState.user.npub === import.meta.env.VITE_REPORTING_NPUB - ) { - const nsfwListFilter: NDKFilter = { - kinds: [NDKKind.ArticleCurationSet], - authors: [pubkey], - '#d': ['nsfw'] + const handleNSFW = () => { + if (navigation.state === 'idle') { + submit( + { + intent: 'nsfw', + value: !isAddedToNSFW + }, + { + method: 'post', + encType: 'application/json' } - - fetchEventFromUserRelays( - nsfwListFilter, - pubkey, - UserRelaysType.Write - ).then((event) => { - if (event) { - // get a list of tags - const tags = event.tags - const existsInNSFWList = - tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== - -1 - - setIsAddedToNSFW(existsInNSFWList) - } - }) - } + ) } - }, [userState, aTag, fetchEventFromUserRelays]) - - const handleBlock = async () => { - let hexPubkey: string - - setIsLoading(true) - setLoadingSpinnerDesc('Getting user pubkey') - - if (userState.auth && userState.user?.pubkey) { - hexPubkey = userState.user.pubkey as string - } else { - hexPubkey = (await window.nostr?.getPublicKey()) as string - } - - if (!hexPubkey) { - 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: [hexPubkey] - } - - // Fetch the mute list event from the relays. This returns the event containing the user's mute list. - const muteListEvent = await 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) { - setIsLoading(false) - setIsBlocked(true) - return toast.warn(`Mod reference is already in user's mute list`) - } - - tags.push(['a', aTag]) - - unsignedEvent = { - pubkey: muteListEvent.pubkey, - kind: NDKKind.MuteList, - content: muteListEvent.content, - created_at: now(), - tags: [...tags] - } - } else { - unsignedEvent = { - pubkey: hexPubkey, - kind: NDKKind.MuteList, - content: '', - created_at: now(), - tags: [['a', aTag]] - } - } - - setLoadingSpinnerDesc('Updating mute list event') - - const isUpdated = await signAndPublish(unsignedEvent, ndk, publish) - if (isUpdated) { - setIsBlocked(true) - } - setIsLoading(false) } - const handleUnblock = async () => { - const pubkey = userState.user?.pubkey as string - - const filter: NDKFilter = { - kinds: [NDKKind.MuteList], - authors: [pubkey] + const handleRepost = () => { + if (navigation.state === 'idle') { + submit( + { + intent: 'repost', + value: !isRepost + }, + { + method: 'post', + encType: 'application/json' + } + ) } - - 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, - pubkey, - 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] !== 'a' || item[1] !== aTag) - } - - setLoadingSpinnerDesc('Updating mute list event') - const isUpdated = await signAndPublish(unsignedEvent, ndk, publish) - if (isUpdated) { - setIsBlocked(false) - } - - setIsLoading(false) - } - - const handleBlockNSFW = async () => { - const pubkey = userState.user?.pubkey as string | undefined - - if (!pubkey) return - - const filter: NDKFilter = { - kinds: [NDKKind.ArticleCurationSet], - authors: [pubkey], - '#d': ['nsfw'] - } - - setIsLoading(true) - setLoadingSpinnerDesc('Finding NSFW list') - - const nsfwListEvent = await fetchEventFromUserRelays( - filter, - pubkey, - UserRelaysType.Write - ) - - let unsignedEvent: UnsignedEvent - - if (nsfwListEvent) { - // get a list of tags - const tags = nsfwListEvent.tags - const alreadyExists = - tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== -1 - - if (alreadyExists) { - setIsLoading(false) - setIsAddedToNSFW(true) - return toast.warn(`Mod reference is already in user's nsfw list`) - } - - tags.push(['a', aTag]) - - unsignedEvent = { - pubkey: nsfwListEvent.pubkey, - kind: NDKKind.ArticleCurationSet, - content: nsfwListEvent.content, - created_at: now(), - tags: [...tags] - } - } else { - unsignedEvent = { - pubkey: pubkey, - kind: NDKKind.ArticleCurationSet, - content: '', - created_at: now(), - tags: [ - ['a', aTag], - ['d', 'nsfw'] - ] - } - } - - setLoadingSpinnerDesc('Updating nsfw list event') - - const isUpdated = await signAndPublish(unsignedEvent, ndk, publish) - if (isUpdated) { - setIsAddedToNSFW(true) - } - setIsLoading(false) - } - - const handleUnblockNSFW = async () => { - const pubkey = userState.user?.pubkey as string - - const filter: NDKFilter = { - kinds: [NDKKind.ArticleCurationSet], - authors: [pubkey], - '#d': ['nsfw'] - } - - setIsLoading(true) - setLoadingSpinnerDesc('Finding NSFW list') - - const nsfwListEvent = await fetchEventFromUserRelays( - filter, - pubkey, - UserRelaysType.Write - ) - - if (!nsfwListEvent) { - toast.error(`Couldn't get nsfw list event from relays`) - return - } - - const tags = nsfwListEvent.tags - - const unsignedEvent: UnsignedEvent = { - pubkey: nsfwListEvent.pubkey, - kind: NDKKind.ArticleCurationSet, - content: nsfwListEvent.content, - created_at: now(), - tags: tags.filter((item) => item[0] !== 'a' || item[1] !== aTag) - } - - setLoadingSpinnerDesc('Updating nsfw list event') - const isUpdated = await signAndPublish(unsignedEvent, ndk, publish) - if (isUpdated) { - setIsAddedToNSFW(false) - } - - setIsLoading(false) } const isAdmin = userState.user?.npub && userState.user.npub === import.meta.env.VITE_REPORTING_NPUB + const game = mod?.game || '' const gameRoute = getGamePageRoute(game) - const editRoute = getModsEditPageRoute(naddr) return ( <> - {isLoading && }
- {showReportPopUp && ( + {!!showReportPopUp && ( setShowReportPopUp(false)} + openedAt={showReportPopUp} + reasons={MOD_REPORT_REASONS} + handleClose={() => setShowReportPopUp(undefined)} /> )} ) } -type ReportPopupProps = { - aTag: string - handleClose: () => void -} - -const MOD_REPORT_REASONS = [ - { label: 'Actually CP', key: 'actuallyCP' }, - { label: 'Spam', key: 'spam' }, - { label: 'Scam', key: 'scam' }, - { label: 'Not a game mod', key: 'notAGameMod' }, - { label: 'Stolen game mod', key: 'stolenGameMod' }, - { label: `Wasn't tagged NSFW`, key: 'wasntTaggedNSFW' }, - { label: 'Other reason', key: 'otherReason' } -] - -const ReportPopup = ({ aTag, handleClose }: ReportPopupProps) => { - const { ndk, fetchEventFromUserRelays, publish } = useNDKContext() - const userState = useAppSelector((state) => state.user) - const [selectedOptions, setSelectedOptions] = useState( - MOD_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 - } - let hexPubkey: string - - setIsLoading(true) - setLoadingSpinnerDesc('Getting user pubkey') - - if (userState.auth && userState.user?.pubkey) { - hexPubkey = userState.user.pubkey as string - } else { - hexPubkey = (await window.nostr?.getPublicKey()) as string - } - - if (!hexPubkey) { - toast.error('Could not get pubkey for reporting mod!') - setIsLoading(false) - return - } - - const reportingNpub = import.meta.env.VITE_REPORTING_NPUB - const reportingPubkey = npubToHex(reportingNpub) - - if (reportingPubkey === hexPubkey) { - 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: [hexPubkey] - } - - // Fetch the mute list event from the relays. This returns the event containing the user's mute list. - const muteListEvent = await 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) { - setIsLoading(false) - return toast.warn(`Mod reference is already in user's mute list`) - } - - tags.push(['a', aTag]) - - unsignedEvent = { - pubkey: muteListEvent.pubkey, - kind: NDKKind.MuteList, - content: muteListEvent.content, - created_at: now(), - tags: [...tags] - } - } else { - unsignedEvent = { - pubkey: hexPubkey, - kind: NDKKind.MuteList, - content: '', - created_at: now(), - tags: [['a', aTag]] - } - } - - 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

-
-
- - - -
-
-
-
-
- - {MOD_REPORT_REASONS.map((r) => ( - handleCheckboxChange(r.key)} - /> - ))} -
- -
-
-
-
-
-
- - ) -} - type BodyProps = { featuredImageUrl: string title: string @@ -934,8 +489,8 @@ const Body = ({ {`ScreenShot-${index}`} openLightBoxOnSlide(index + 1)} /> ))} @@ -1223,43 +778,16 @@ const Download = ({ } const DisplayModAuthorBlogs = () => { - const { naddr } = useParams() - const [blogs, setBlogs] = useState[]>() - const { fetchEvents } = useNDKContext() + const { latest } = useLoaderData() as ModPageLoaderResult - useDidMount(() => { - const fetchBlogs = async () => { - try { - const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`) - const { pubkey } = decoded.data - const latestBlogPosts = await fetchEvents({ - authors: [pubkey], - kinds: [kinds.LongFormArticle], - limit: 3 - }) - setBlogs(latestBlogPosts.map(extractBlogCardDetails)) - } catch (error) { - log( - true, - LogType.Error, - 'An error occurred in fetching blog details from relays', - error - ) - return null - } - } - - fetchBlogs() - }) - - if (!blogs?.length) return null + if (!latest?.length) return null return (

Creator's Blog Posts

- {blogs?.map((b) => ( + {latest?.map((b) => ( ))}
diff --git a/src/pages/mod/loader.ts b/src/pages/mod/loader.ts new file mode 100644 index 0000000..81f4a1d --- /dev/null +++ b/src/pages/mod/loader.ts @@ -0,0 +1,228 @@ +import { NDKFilter } from '@nostr-dev-kit/ndk' +import { PROFILE_BLOG_FILTER_LIMIT } from '../../constants' +import { NDKContextType } from 'contexts/NDKContext' +import { kinds, nip19 } from 'nostr-tools' +import { LoaderFunctionArgs, redirect } from 'react-router-dom' +import { appRoutes } from 'routes' +import { store } from 'store' +import { + FilterOptions, + ModeratedFilter, + ModPageLoaderResult, + NSFWFilter +} from 'types' +import { + DEFAULT_FILTER_OPTIONS, + extractBlogCardDetails, + extractModData, + getLocalStorageItem, + log, + LogType +} from 'utils' + +export const modRouteLoader = + (ndkContext: NDKContextType) => + async ({ params }: LoaderFunctionArgs) => { + const { naddr } = params + if (!naddr) { + log(true, LogType.Error, 'Required naddr.') + return redirect(appRoutes.blogs) + } + + // Decode from naddr + let pubkey: string | undefined + let identifier: string | undefined + let kind: number | undefined + try { + const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`) + identifier = decoded.data.identifier + kind = decoded.data.kind + pubkey = decoded.data.pubkey + } catch (error) { + log(true, LogType.Error, `Failed to decode naddr: ${naddr}`, error) + throw new Error('Failed to fetch the blog. The address might be wrong') + } + + const userState = store.getState().user + const loggedInUserPubkey = userState?.user?.pubkey as string | undefined + + try { + // Set up the filters + // Main mod content + const modFilter: NDKFilter = { + '#a': [identifier], + authors: [pubkey], + kinds: [kind] + } + + // Get the blog filter options for latest blogs + const filterOptions = JSON.parse( + getLocalStorageItem('filter-blog', DEFAULT_FILTER_OPTIONS) + ) as FilterOptions + + // Fetch more in case the current blog is included in the latest and filters remove some + const latestFilter: NDKFilter = { + authors: [pubkey], + kinds: [kinds.LongFormArticle], + limit: PROFILE_BLOG_FILTER_LIMIT + } + // Add source filter + if (filterOptions.source === window.location.host) { + latestFilter['#r'] = [filterOptions.source] + } + // Filter by NSFW tag + // NSFWFilter.Only_NSFW -> fetch with content-warning label + // NSFWFilter.Show_NSFW -> filter not needed + // NSFWFilter.Hide_NSFW -> up the limit and filter after fetch + if (filterOptions.nsfw === NSFWFilter.Only_NSFW) { + latestFilter['#L'] = ['content-warning'] + } + + // Parallel fetch blog event, latest events, mute, and nsfw lists + const settled = await Promise.allSettled([ + ndkContext.fetchEvent(modFilter), + ndkContext.fetchEvents(latestFilter), + ndkContext.getMuteLists(loggedInUserPubkey), // Pass pubkey for logged-in users + ndkContext.getNSFWList() + ]) + + const result: ModPageLoaderResult = { + mod: undefined, + latest: [], + isAddedToNSFW: false, + isBlocked: false, + isRepost: false + } + + // Check the mod event result + const fetchEventResult = settled[0] + if (fetchEventResult.status === 'fulfilled' && fetchEventResult.value) { + // Extract the mod data from the event + result.mod = extractModData(fetchEventResult.value) + } else if (fetchEventResult.status === 'rejected') { + log( + true, + LogType.Error, + 'Unable to fetch the blog event.', + fetchEventResult.reason + ) + } + + // Throw an error if we are missing the main mod result + // Handle it with the react-router's errorComponent + if (!result.mod) { + throw new Error('We are unable to find the mod on the relays') + } + + // Check the lateast blog events + const fetchEventsResult = settled[1] + if (fetchEventsResult.status === 'fulfilled' && fetchEventsResult.value) { + // Extract the blog card details from the events + result.latest = fetchEventsResult.value.map(extractBlogCardDetails) + } else if (fetchEventsResult.status === 'rejected') { + log( + true, + LogType.Error, + 'Unable to fetch the latest blog events.', + fetchEventsResult.reason + ) + } + + const muteLists = settled[2] + if (muteLists.status === 'fulfilled' && muteLists.value) { + if (muteLists && muteLists.value) { + if (result.mod && result.mod.aTag) { + if ( + muteLists.value.admin.replaceableEvents.includes( + result.mod.aTag + ) || + muteLists.value.user.replaceableEvents.includes(result.mod.aTag) + ) { + result.isBlocked = true + } + } + + // Moderate the latest + const isAdmin = + userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB + const isOwner = + userState.user?.pubkey && userState.user.pubkey === pubkey + const isUnmoderatedFully = + filterOptions.moderated === ModeratedFilter.Unmoderated_Fully + + // Only apply filtering if the user is not an admin or the admin has not selected "Unmoderated Fully" + // Allow "Unmoderated Fully" when author visits own profile + if (!((isAdmin || isOwner) && isUnmoderatedFully)) { + result.latest = result.latest.filter( + (b) => + !muteLists.value.admin.authors.includes(b.author!) && + !muteLists.value.admin.replaceableEvents.includes(b.aTag!) + ) + } + + if (filterOptions.moderated === ModeratedFilter.Moderated) { + result.latest = result.latest.filter( + (b) => + !muteLists.value.user.authors.includes(b.author!) && + !muteLists.value.user.replaceableEvents.includes(b.aTag!) + ) + } + } + } else if (muteLists.status === 'rejected') { + log(true, LogType.Error, 'Issue fetching mute list', muteLists.reason) + } + + const nsfwList = settled[3] + if (nsfwList.status === 'fulfilled' && nsfwList.value) { + // Check if the mod is marked as NSFW + // Mark it as NSFW only if it's missing the tag + if (result.mod) { + const isMissingNsfwTag = + !result.mod.nsfw && + result.mod.aTag && + nsfwList.value.includes(result.mod.aTag) + + if (isMissingNsfwTag) { + result.mod.nsfw = true + } + + if (result.mod.aTag && nsfwList.value.includes(result.mod.aTag)) { + result.isAddedToNSFW = true + } + } + // Check the latest blogs too + result.latest = result.latest.map((b) => { + // Add nsfw tag if it's missing + const isMissingNsfwTag = + !b.nsfw && b.aTag && nsfwList.value.includes(b.aTag) + + if (isMissingNsfwTag) { + b.nsfw = true + } + return b + }) + } else if (nsfwList.status === 'rejected') { + log(true, LogType.Error, 'Issue fetching nsfw list', nsfwList.reason) + } + + // Filter latest, sort and take only three + result.latest = result.latest + .filter( + // Filter out the NSFW if selected + (b) => !(b.nsfw && filterOptions.nsfw === NSFWFilter.Hide_NSFW) + ) + .sort((a, b) => + a.published_at && b.published_at ? b.published_at - a.published_at : 0 + ) + .slice(0, 3) + + return result + } catch (error) { + let message = 'An error occurred in fetching mod details from relays' + log(true, LogType.Error, message, error) + if (error instanceof Error) { + message = error.message + throw new Error(message) + } + } + } diff --git a/src/pages/write/action.tsx b/src/pages/write/action.ts similarity index 100% rename from src/pages/write/action.tsx rename to src/pages/write/action.ts diff --git a/src/routes/index.tsx b/src/routes/index.tsx index aec86c6..04d8fb1 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -5,12 +5,14 @@ import { SearchPage } from '../pages/search' import { AboutPage } from '../pages/about' import { GamesPage } from '../pages/games' import { HomePage } from '../pages/home' -import { ModPage } from '../pages/mod' import { ModsPage } from '../pages/mods' +import { ModPage } from '../pages/mod' +import { modRouteLoader } from '../pages/mod/loader' +import { modRouteAction } from '../pages/mod/action' +import { SubmitModPage } from '../pages/submitMod' import { ProfilePage } from '../pages/profile' import { profileRouteLoader } from 'pages/profile/loader' import { SettingsPage } from '../pages/settings' -import { SubmitModPage } from '../pages/submitMod' import { GamePage } from '../pages/game' import { NotFoundPage } from '../pages/404' import { FeedLayout } from '../layout/feed' @@ -18,12 +20,12 @@ import { FeedPage } from '../pages/feed' import { NotificationsPage } from '../pages/notifications' import { WritePage } from '../pages/write' import { writeRouteAction } from '../pages/write/action' -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' +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 { reportRouteAction } from '../actions/report' export const appRoutes = { index: '/', @@ -32,6 +34,7 @@ export const appRoutes = { game: '/game/:name', mods: '/mods', mod: '/mod/:naddr', + modReport_actionOnly: '/mod/:naddr/report', about: '/about', blogs: '/blog', blog: '/blog/:naddr', @@ -88,7 +91,14 @@ export const routerWithNdkContext = (context: NDKContextType) => }, { path: appRoutes.mod, - element: + element: , + loader: modRouteLoader(context), + action: modRouteAction(context), + errorElement: + }, + { + path: appRoutes.modReport_actionOnly, + action: reportRouteAction(context) }, { path: appRoutes.about, @@ -115,7 +125,7 @@ export const routerWithNdkContext = (context: NDKContextType) => }, { path: appRoutes.blogReport_actionOnly, - action: blogReportRouteAction(context) + action: reportRouteAction(context) }, { path: appRoutes.submitMod, diff --git a/src/types/mod.ts b/src/types/mod.ts index c9d14f8..bfad46f 100644 --- a/src/types/mod.ts +++ b/src/types/mod.ts @@ -1,4 +1,5 @@ import { Event } from 'nostr-tools' +import { BlogDetails } from 'types' export enum CommentEventStatus { Publishing = 'Publishing comment...', @@ -26,6 +27,7 @@ export interface ModFormState { featuredImageUrl: string summary: string nsfw: boolean + repost: boolean screenshotsUrls: string[] tags: string downloadUrls: DownloadUrl[] @@ -52,3 +54,11 @@ export interface MuteLists { authors: string[] replaceableEvents: string[] } + +export interface ModPageLoaderResult { + mod: ModDetails | undefined + latest: Partial[] + isAddedToNSFW: boolean + isBlocked: boolean + isRepost: boolean +} diff --git a/src/types/report.ts b/src/types/report.ts new file mode 100644 index 0000000..907cbe9 --- /dev/null +++ b/src/types/report.ts @@ -0,0 +1,4 @@ +export interface ReportReason { + label: string + key: string +} diff --git a/src/utils/curationSets.ts b/src/utils/curationSets.ts new file mode 100644 index 0000000..2ddb153 --- /dev/null +++ b/src/utils/curationSets.ts @@ -0,0 +1,159 @@ +import { NDKFilter } from '@nostr-dev-kit/ndk' +import { NDKContextType } from 'contexts/NDKContext' +import { UnsignedEvent, kinds } from 'nostr-tools' +import { toast } from 'react-toastify' +import { UserRelaysType } from 'types' +import { now, signAndPublish } from './nostr' + +interface CurationSetArgs { + dTag: CurationSetIdentifiers + pubkey: string + ndkContext: NDKContextType +} + +/** + * Used for dTag when updating + */ +export enum CurationSetIdentifiers { + NSFW = 'nsfw', + Repost = 'repost' +} + +/** + * Create the article curation set (kind: 30004) for the user (pubkey) + * with initial article (optional) + * @see https://github.com/nostr-protocol/nips/blob/master/51.md#sets + */ +export async function createCurationSet( + { dTag, pubkey, ndkContext }: CurationSetArgs, + initialArticle?: string +) { + const curationSetName = `${dTag} - Degmods's curation set` + const tags = [ + ['d', dTag], + ['title', curationSetName] + ] + if (initialArticle) { + tags.push(['a', initialArticle]) + } + const unsignedEvent: UnsignedEvent = { + pubkey: pubkey, + kind: kinds.Curationsets, + content: '', + created_at: now(), + tags + } + const isUpdated = await signAndPublish( + unsignedEvent, + ndkContext.ndk, + ndkContext.publish + ) + if (!isUpdated) { + toast.error(`Failed to create user's ${dTag} curation set`) + } + return isUpdated +} + +export async function getCurationSet({ + dTag, + pubkey, + ndkContext +}: CurationSetArgs) { + const filter: NDKFilter = { + kinds: [kinds.Curationsets], + authors: [pubkey], + '#d': [dTag] + } + + const nsfwListEvent = await ndkContext.fetchEventFromUserRelays( + filter, + pubkey, + UserRelaysType.Write + ) + + return nsfwListEvent +} + +/** + * Update the article curation set (kind: 30004) for the user (pubkey) + * Add the aTag to the dTag list + * @see https://github.com/nostr-protocol/nips/blob/master/51.md#sets + */ +export async function addToCurationSet( + curationSetArgs: CurationSetArgs, + aTag: string +) { + const curationSetEvent = await getCurationSet(curationSetArgs) + + let isUpdated = false + if (curationSetEvent) { + const { dTag, pubkey, ndkContext } = curationSetArgs + const tags = curationSetEvent.tags + const alreadyExists = + tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== -1 + + if (alreadyExists) { + toast.warn(`Already in user's ${dTag} list`) + return false + } + + tags.push(['a', aTag]) + + const unsignedEvent = { + pubkey: pubkey, + kind: kinds.Curationsets, + content: curationSetEvent.content, + created_at: now(), + tags: [...tags] + } + + isUpdated = await signAndPublish( + unsignedEvent, + ndkContext.ndk, + ndkContext.publish + ) + if (!isUpdated) { + toast.error(`Failed to update user's ${dTag} list`) + } + } else { + isUpdated = await createCurationSet(curationSetArgs, aTag) + } + + return isUpdated +} + +/** + * Update the article curation set (kind: 30004) for the user (pubkey) + * Remove the aTag from the dTag list + */ +export async function removeFromCurationSet( + curationSetArgs: CurationSetArgs, + aTag: string +) { + const curationSetEvent = await getCurationSet(curationSetArgs) + const { dTag, pubkey, ndkContext } = curationSetArgs + + if (!curationSetEvent) { + toast.error(`Couldn't get ${dTag} list event from relays`) + return false + } + const tags = curationSetEvent.tags + + const unsignedEvent: UnsignedEvent = { + pubkey: pubkey, + kind: kinds.Curationsets, + content: curationSetEvent.content, + created_at: now(), + tags: tags.filter((item) => item[0] !== 'a' || item[1] !== aTag) + } + + const isUpdated = await signAndPublish( + unsignedEvent, + ndkContext.ndk, + ndkContext.publish + ) + if (!isUpdated) { + toast.error(`Failed to update user's ${dTag} list`) + } + return isUpdated +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 3391eb9..91fe37b 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -6,3 +6,4 @@ export * from './zap' export * from './localStorage' export * from './consts' export * from './blog' +export * from './curationSets' diff --git a/src/utils/mod.ts b/src/utils/mod.ts index 09537fe..95f82a3 100644 --- a/src/utils/mod.ts +++ b/src/utils/mod.ts @@ -39,6 +39,7 @@ export const extractModData = (event: Event | NDKEvent): ModDetails => { featuredImageUrl: getFirstTagValue('featuredImageUrl'), summary: getFirstTagValue('summary'), nsfw: getFirstTagValue('nsfw') === 'true', + repost: getFirstTagValue('repost') === 'true', screenshotsUrls: getTagValue(event, 'screenshotsUrls') || [], tags: getTagValue(event, 'tags') || [], downloadUrls: (getTagValue(event, 'downloadUrls') || []).map((item) => @@ -118,6 +119,7 @@ export const initializeFormState = ( featuredImageUrl: existingModData?.featuredImageUrl || '', summary: existingModData?.summary || '', nsfw: existingModData?.nsfw || false, + repost: existingModData?.repost || false, screenshotsUrls: existingModData?.screenshotsUrls || [''], tags: existingModData?.tags.join(',') || '', downloadUrls: existingModData?.downloadUrls || [ -- 2.34.1 From 6c0ac7d59d6257a369c8db45d45aba745c162c11 Mon Sep 17 00:00:00 2001 From: enes Date: Wed, 27 Nov 2024 14:42:48 +0100 Subject: [PATCH 09/14] fix: mod edit route --- src/pages/mod/index.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pages/mod/index.tsx b/src/pages/mod/index.tsx index e1746cf..da3cb91 100644 --- a/src/pages/mod/index.tsx +++ b/src/pages/mod/index.tsx @@ -15,7 +15,7 @@ import { toast } from 'react-toastify' import { BlogCard } from '../../components/BlogCard' import { ProfileSection } from '../../components/ProfileSection' import { useAppSelector, useBodyScrollDisable } from '../../hooks' -import { getGamePageRoute } from '../../routes' +import { getGamePageRoute, getModsEditPageRoute } from '../../routes' import '../../styles/comments.css' import '../../styles/downloads.css' import '../../styles/innerPage.css' @@ -185,6 +185,7 @@ export const ModPage = () => { } const Game = () => { + const { naddr } = useParams() const navigation = useNavigation() const { mod, isAddedToNSFW, isBlocked, isRepost } = useLoaderData() as ModPageLoaderResult @@ -245,6 +246,7 @@ const Game = () => { const game = mod?.game || '' const gameRoute = getGamePageRoute(game) + const editRoute = getModsEditPageRoute(naddr ? naddr : '') return ( <> @@ -281,7 +283,7 @@ const Game = () => { {userState.auth && userState.user?.pubkey === mod?.author && ( Date: Wed, 27 Nov 2024 17:17:54 +0100 Subject: [PATCH 10/14] feat: add repost and original author fields --- src/components/Inputs.tsx | 2 +- src/components/ModCard.tsx | 5 ++ src/components/ModForm.tsx | 91 ++++++++++++++++++++++--------- src/components/OriginalAuthor.tsx | 48 ++++++++++++++++ src/components/ProfileLink.tsx | 15 +++++ src/pages/mod/index.tsx | 20 ++++++- src/types/mod.ts | 1 + src/utils/mod.ts | 2 + 8 files changed, 157 insertions(+), 27 deletions(-) create mode 100644 src/components/OriginalAuthor.tsx create mode 100644 src/components/ProfileLink.tsx diff --git a/src/components/Inputs.tsx b/src/components/Inputs.tsx index 38d48af..95b4f48 100644 --- a/src/components/Inputs.tsx +++ b/src/components/Inputs.tsx @@ -7,7 +7,7 @@ import '../styles/styles.css' import '../styles/tiptap.scss' interface InputFieldProps { - label: string + label: string | React.ReactElement description?: string type?: 'text' | 'textarea' | 'richtext' placeholder: string diff --git a/src/components/ModCard.tsx b/src/components/ModCard.tsx index c54b300..108d33f 100644 --- a/src/components/ModCard.tsx +++ b/src/components/ModCard.tsx @@ -57,6 +57,11 @@ export const ModCard = React.memo((props: ModDetails) => {

NSFW

)} + {props.repost && ( +
+

REPOST

+
+ )}

{props.title}

diff --git a/src/components/ModForm.tsx b/src/components/ModForm.tsx index 7411c47..ea54912 100644 --- a/src/components/ModForm.tsx +++ b/src/components/ModForm.tsx @@ -29,6 +29,7 @@ import { import { CheckboxField, InputError, InputField } from './Inputs' import { LoadingSpinner } from './LoadingSpinner' import { NDKEvent } from '@nostr-dev-kit/ndk' +import { OriginalAuthor } from './OriginalAuthor' interface FormErrors { game?: string @@ -40,6 +41,8 @@ interface FormErrors { screenshotsUrls?: string[] tags?: string downloadUrls?: string[] + author?: string + originalAuthor?: string } interface GameOption { @@ -198,36 +201,42 @@ export const ModForm = ({ existingModData }: ModFormProps) => { const aTag = formState.aTag || `${kinds.ClassifiedListing}:${hexPubkey}:${uuid}` + const tags = [ + ['d', uuid], + ['a', aTag], + ['r', formState.rTag], + ['t', T_TAG_VALUE], + [ + 'published_at', + existingModData + ? existingModData.published_at.toString() + : currentTimeStamp.toString() + ], + ['game', formState.game], + ['title', formState.title], + ['featuredImageUrl', formState.featuredImageUrl], + ['summary', formState.summary], + ['nsfw', formState.nsfw.toString()], + ['repost', formState.repost.toString()], + ['screenshotsUrls', ...formState.screenshotsUrls], + ['tags', ...formState.tags.split(',')], + [ + 'downloadUrls', + ...formState.downloadUrls.map((downloadUrl) => + JSON.stringify(downloadUrl) + ) + ] + ] + if (formState.repost && formState.originalAuthor) { + tags.push(['originalAuthor', formState.originalAuthor]) + } + const unsignedEvent: UnsignedEvent = { kind: kinds.ClassifiedListing, created_at: currentTimeStamp, pubkey: hexPubkey, content: formState.body, - tags: [ - ['d', uuid], - ['a', aTag], - ['r', formState.rTag], - ['t', T_TAG_VALUE], - [ - 'published_at', - existingModData - ? existingModData.published_at.toString() - : currentTimeStamp.toString() - ], - ['game', formState.game], - ['title', formState.title], - ['featuredImageUrl', formState.featuredImageUrl], - ['summary', formState.summary], - ['nsfw', formState.nsfw.toString()], - ['screenshotsUrls', ...formState.screenshotsUrls], - ['tags', ...formState.tags.split(',')], - [ - 'downloadUrls', - ...formState.downloadUrls.map((downloadUrl) => - JSON.stringify(downloadUrl) - ) - ] - ] + tags } const signedEvent = await window.nostr @@ -318,6 +327,13 @@ export const ModForm = ({ existingModData }: ModFormProps) => { } } + if ( + formState.repost && + (!formState.originalAuthor || formState.originalAuthor === '') + ) { + errors.originalAuthor = 'Original author field can not be empty' + } + if (formState.tags === '') { errors.tags = 'Tags field can not be empty' } @@ -397,6 +413,31 @@ export const ModForm = ({ existingModData }: ModFormProps) => { handleChange={handleCheckboxChange} type='stylized' /> + + {formState.repost && ( + <> + + Created by:{' '} + {} + + } + type='text' + placeholder="Original author's name, npub or nprofile" + name='originalAuthor' + value={formState.originalAuthor || ''} + error={formErrors.originalAuthor || ''} + onChange={handleInputChange} + /> + + )}
diff --git a/src/components/OriginalAuthor.tsx b/src/components/OriginalAuthor.tsx new file mode 100644 index 0000000..60dc4dc --- /dev/null +++ b/src/components/OriginalAuthor.tsx @@ -0,0 +1,48 @@ +import { nip19 } from 'nostr-tools' +import { appRoutes, getProfilePageRoute } from 'routes' +import { npubToHex } from 'utils' +import { ProfileLink } from './ProfileLink' + +interface OriginalAuthorProps { + value: string + fallback?: boolean +} + +export const OriginalAuthor = ({ + value, + fallback = false +}: OriginalAuthorProps) => { + let profilePubkey + let displayName = '[name not set up]' + + // Try to decode/encode depending on what we send to link + let profileRoute = appRoutes.home + try { + if (value.startsWith('nprofile1')) { + const decoded = nip19.decode(value as `nprofile1${string}`) + profileRoute = getProfilePageRoute(value) + profilePubkey = decoded?.data.pubkey + } else if (value.startsWith('npub1')) { + profilePubkey = npubToHex(value) + const nprofile = profilePubkey + ? nip19.nprofileEncode({ + pubkey: profilePubkey + }) + : undefined + + if (nprofile) { + profileRoute = getProfilePageRoute(nprofile) + } + } else { + displayName = value + } + } catch (error) { + console.error('Failed to create profile link:', error) + displayName = value + } + + if (profileRoute && profilePubkey) + return + + return fallback ? displayName : null +} diff --git a/src/components/ProfileLink.tsx b/src/components/ProfileLink.tsx new file mode 100644 index 0000000..0fcdfca --- /dev/null +++ b/src/components/ProfileLink.tsx @@ -0,0 +1,15 @@ +import { useProfile } from 'hooks/useProfile' +import { Link } from 'react-router-dom' + +interface ProfileLinkProps { + pubkey: string + profileRoute: string +} + +export const ProfileLink = ({ pubkey, profileRoute }: ProfileLinkProps) => { + const profile = useProfile(pubkey) + const displayName = + profile?.displayName || profile?.name || '[name not set up]' + + return {displayName} +} diff --git a/src/pages/mod/index.tsx b/src/pages/mod/index.tsx index da3cb91..53d2944 100644 --- a/src/pages/mod/index.tsx +++ b/src/pages/mod/index.tsx @@ -38,6 +38,7 @@ import { Interactions } from 'components/Internal/Interactions' import { ReportPopup } from 'components/ReportPopup' import { Spinner } from 'components/Spinner' import { RouterLoadingSpinner } from 'components/LoadingSpinner' +import { OriginalAuthor } from 'components/OriginalAuthor' const MOD_REPORT_REASONS = [ { label: 'Actually CP', key: 'actuallyCP' }, @@ -105,6 +106,8 @@ export const ModPage = () => { screenshotsUrls={mod.screenshotsUrls} tags={mod.tags} nsfw={mod.nsfw} + repost={mod.repost} + originalAuthor={mod.originalAuthor} /> { const postBodyRef = useRef(null) const viewFullPostBtnRef = useRef(null) @@ -504,6 +511,17 @@ const Body = ({
)} + {repost && ( +
+

+ REPOST. Original Author:{' '} + {!!originalAuthor && ( + + )} +

+
+ )} + {tags.map((tag, index) => (
{tag} diff --git a/src/types/mod.ts b/src/types/mod.ts index bfad46f..bc27e18 100644 --- a/src/types/mod.ts +++ b/src/types/mod.ts @@ -28,6 +28,7 @@ export interface ModFormState { summary: string nsfw: boolean repost: boolean + originalAuthor?: string screenshotsUrls: string[] tags: string downloadUrls: DownloadUrl[] diff --git a/src/utils/mod.ts b/src/utils/mod.ts index 95f82a3..24b59bd 100644 --- a/src/utils/mod.ts +++ b/src/utils/mod.ts @@ -40,6 +40,7 @@ export const extractModData = (event: Event | NDKEvent): ModDetails => { summary: getFirstTagValue('summary'), nsfw: getFirstTagValue('nsfw') === 'true', repost: getFirstTagValue('repost') === 'true', + originalAuthor: getFirstTagValue('originalAuthor'), screenshotsUrls: getTagValue(event, 'screenshotsUrls') || [], tags: getTagValue(event, 'tags') || [], downloadUrls: (getTagValue(event, 'downloadUrls') || []).map((item) => @@ -120,6 +121,7 @@ export const initializeFormState = ( summary: existingModData?.summary || '', nsfw: existingModData?.nsfw || false, repost: existingModData?.repost || false, + originalAuthor: existingModData?.originalAuthor || undefined, screenshotsUrls: existingModData?.screenshotsUrls || [''], tags: existingModData?.tags.join(',') || '', downloadUrls: existingModData?.downloadUrls || [ -- 2.34.1 From 376164cbf48a72ad5b55202b47f1a050e07c3d4f Mon Sep 17 00:00:00 2001 From: enes Date: Wed, 27 Nov 2024 19:56:19 +0100 Subject: [PATCH 11/14] refactor: add repost tag if missing --- src/hooks/useCuratedSet.tsx | 15 +++++++++++++++ src/pages/mod/index.tsx | 9 ++++++--- src/pages/mod/loader.ts | 27 ++++++++++++++++++++++++++- src/pages/mods.tsx | 17 +++++++++++++++-- src/pages/profile/index.tsx | 21 ++++++++++++++------- src/pages/profile/loader.ts | 30 ++++++++++++++++++++++++++---- src/utils/curationSets.ts | 32 ++++++++++++++++++++++++++++++-- 7 files changed, 132 insertions(+), 19 deletions(-) create mode 100644 src/hooks/useCuratedSet.tsx diff --git a/src/hooks/useCuratedSet.tsx b/src/hooks/useCuratedSet.tsx new file mode 100644 index 0000000..85ecaab --- /dev/null +++ b/src/hooks/useCuratedSet.tsx @@ -0,0 +1,15 @@ +import { useState } from 'react' +import { useNDKContext } from './useNDKContext' +import { useDidMount } from './useDidMount' +import { CurationSetIdentifiers, getReportingSet } from 'utils' + +export const useCuratedSet = (type: CurationSetIdentifiers) => { + const ndkContext = useNDKContext() + const [curatedSet, setCuratedSet] = useState([]) + + useDidMount(async () => { + setCuratedSet(await getReportingSet(type, ndkContext)) + }) + + return curatedSet +} diff --git a/src/pages/mod/index.tsx b/src/pages/mod/index.tsx index 53d2944..9581c3f 100644 --- a/src/pages/mod/index.tsx +++ b/src/pages/mod/index.tsx @@ -514,9 +514,12 @@ const Body = ({ {repost && (

- REPOST. Original Author:{' '} - {!!originalAuthor && ( - + REPOST + {originalAuthor && originalAuthor !== '' && ( + <> + . Original Author:{' '} + + )}

diff --git a/src/pages/mod/loader.ts b/src/pages/mod/loader.ts index 81f4a1d..22476f1 100644 --- a/src/pages/mod/loader.ts +++ b/src/pages/mod/loader.ts @@ -12,10 +12,12 @@ import { NSFWFilter } from 'types' import { + CurationSetIdentifiers, DEFAULT_FILTER_OPTIONS, extractBlogCardDetails, extractModData, getLocalStorageItem, + getReportingSet, log, LogType } from 'utils' @@ -83,7 +85,8 @@ export const modRouteLoader = ndkContext.fetchEvent(modFilter), ndkContext.fetchEvents(latestFilter), ndkContext.getMuteLists(loggedInUserPubkey), // Pass pubkey for logged-in users - ndkContext.getNSFWList() + getReportingSet(CurationSetIdentifiers.NSFW, ndkContext), + getReportingSet(CurationSetIdentifiers.Repost, ndkContext) ]) const result: ModPageLoaderResult = { @@ -205,6 +208,28 @@ export const modRouteLoader = log(true, LogType.Error, 'Issue fetching nsfw list', nsfwList.reason) } + const repostList = settled[4] + if (repostList.status === 'fulfilled' && repostList.value) { + // Check if the mod is marked as Repost + // Mark it as Repost only if it's missing the tag + if (result.mod) { + const isMissingRepostTag = + !result.mod.repost && + result.mod.aTag && + repostList.value.includes(result.mod.aTag) + + if (isMissingRepostTag) { + result.mod.repost = true + } + + if (result.mod.aTag && repostList.value.includes(result.mod.aTag)) { + result.isRepost = true + } + } + } else if (repostList.status === 'rejected') { + log(true, LogType.Error, 'Issue fetching nsfw list', repostList.reason) + } + // Filter latest, sort and take only three result.latest = result.latest .filter( diff --git a/src/pages/mods.tsx b/src/pages/mods.tsx index 31dd301..72c8c7a 100644 --- a/src/pages/mods.tsx +++ b/src/pages/mods.tsx @@ -19,8 +19,13 @@ import '../styles/pagination.css' import '../styles/search.css' import '../styles/styles.css' import { FilterOptions, ModDetails } from '../types' -import { DEFAULT_FILTER_OPTIONS, scrollIntoView } from 'utils' +import { + CurationSetIdentifiers, + DEFAULT_FILTER_OPTIONS, + scrollIntoView +} from 'utils' import { SearchInput } from 'components/SearchInput' +import { useCuratedSet } from 'hooks/useCuratedSet' export const ModsPage = () => { const scrollTargetRef = useRef(null) @@ -97,6 +102,14 @@ export const ModsPage = () => { muteLists ) + // Add repost tag to mods included in repostList + const repostList = useCuratedSet(CurationSetIdentifiers.Repost) + const filteredModListRepost = filteredModList.map((mod) => { + return !mod.repost && repostList.includes(mod.aTag) + ? { ...mod, repost: true } + : mod + }) + return ( <> {isFetching && } @@ -111,7 +124,7 @@ export const ModsPage = () => {
- {filteredModList.map((mod) => ( + {filteredModListRepost.map((mod) => ( ))}
diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx index 56758a6..83dfa51 100644 --- a/src/pages/profile/index.tsx +++ b/src/pages/profile/index.tsx @@ -10,9 +10,7 @@ import { useAppSelector, useFilteredMods, useLocalStorage, - useMuteLists, - useNDKContext, - useNSFWList + useNDKContext } from 'hooks' import { kinds, UnsignedEvent } from 'nostr-tools' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -47,7 +45,10 @@ export const ProfilePage = () => { profilePubkey, profile, isBlocked: _isBlocked, - isOwnProfile + isOwnProfile, + repostList, + muteLists, + nsfwList } = useLoaderData() as ProfilePageLoaderResult const scrollTargetRef = useRef(null) const { ndk, publish, fetchEventFromUserRelays, fetchMods } = useNDKContext() @@ -200,8 +201,6 @@ export const ProfilePage = () => { const [filterOptions] = useLocalStorage(filterKey, { ...DEFAULT_FILTER_OPTIONS }) - const muteLists = useMuteLists() - const nsfwList = useNSFWList() const handleNext = useCallback(() => { setIsLoading(true) @@ -263,6 +262,7 @@ export const ProfilePage = () => { break } }, [filterOptions.source, tab, fetchMods, profilePubkey]) + const filteredModList = useFilteredMods( mods, userState, @@ -272,6 +272,13 @@ export const ProfilePage = () => { profilePubkey ) + // Add repost tag to mods included in repostList + const filteredModListRepost = filteredModList.map((mod) => { + return !mod.repost && repostList.includes(mod.aTag) + ? { ...mod, repost: true } + : mod + }) + return (
@@ -422,7 +429,7 @@ export const ProfilePage = () => {
- {filteredModList.map((mod) => ( + {filteredModListRepost.map((mod) => ( ))}
diff --git a/src/pages/profile/loader.ts b/src/pages/profile/loader.ts index f3ac4c0..b9c13e9 100644 --- a/src/pages/profile/loader.ts +++ b/src/pages/profile/loader.ts @@ -4,7 +4,13 @@ import { LoaderFunctionArgs, redirect } from 'react-router-dom' import { appRoutes, getProfilePageRoute } from 'routes' import { store } from 'store' import { MuteLists, UserProfile } from 'types' -import { log, LogType, npubToHex } from 'utils' +import { + CurationSetIdentifiers, + getReportingSet, + log, + LogType, + npubToHex +} from 'utils' export interface ProfilePageLoaderResult { profilePubkey: string @@ -16,6 +22,7 @@ export interface ProfilePageLoaderResult { user: MuteLists } nsfwList: string[] + repostList: string[] } export const profileRouteLoader = @@ -87,7 +94,8 @@ export const profileRouteLoader = replaceableEvents: [] } }, - nsfwList: [] + nsfwList: [], + repostList: [] } // Check if user the user is logged in @@ -98,7 +106,8 @@ export const profileRouteLoader = const settled = await Promise.allSettled([ ndkContext.findMetadata(profilePubkey), ndkContext.getMuteLists(userPubkey), - ndkContext.getNSFWList() + getReportingSet(CurationSetIdentifiers.NSFW, ndkContext), + getReportingSet(CurationSetIdentifiers.Repost, ndkContext) ]) // Check the profile event result @@ -138,10 +147,23 @@ export const profileRouteLoader = log( true, LogType.Error, - 'Failed to fetch mutelist.', + 'Failed to fetch nsfwlist.', nsfwListResult.reason ) } + // Check the profile event result + const repostListResult = settled[3] + if (repostListResult.status === 'fulfilled' && repostListResult.value) { + result.repostList = repostListResult.value + } else if (repostListResult.status === 'rejected') { + log( + true, + LogType.Error, + 'Failed to fetch repost list.', + repostListResult.reason + ) + } + return result } diff --git a/src/utils/curationSets.ts b/src/utils/curationSets.ts index 2ddb153..e117789 100644 --- a/src/utils/curationSets.ts +++ b/src/utils/curationSets.ts @@ -1,9 +1,9 @@ -import { NDKFilter } from '@nostr-dev-kit/ndk' +import { NDKFilter, NDKList } from '@nostr-dev-kit/ndk' import { NDKContextType } from 'contexts/NDKContext' import { UnsignedEvent, kinds } from 'nostr-tools' import { toast } from 'react-toastify' import { UserRelaysType } from 'types' -import { now, signAndPublish } from './nostr' +import { now, npubToHex, signAndPublish } from './nostr' interface CurationSetArgs { dTag: CurationSetIdentifiers @@ -54,6 +54,34 @@ export async function createCurationSet( return isUpdated } +export async function getReportingSet( + dTag: CurationSetIdentifiers, + ndkContext: NDKContextType +) { + const result: string[] = [] + const reportingNpub = import.meta.env.VITE_REPORTING_NPUB + const hexKey = npubToHex(reportingNpub) + + if (hexKey) { + const event = await ndkContext.fetchEvent({ + kinds: [kinds.Curationsets], + authors: [hexKey], + '#d': [dTag] + }) + + if (event) { + const list = NDKList.from(event) + list.items.forEach((item) => { + if (item[0] === 'a') { + result.push(item[1]) + } + }) + } + } + + return result +} + export async function getCurationSet({ dTag, pubkey, -- 2.34.1 From b1d578c32931bf263750278a7e349908e9c3fd98 Mon Sep 17 00:00:00 2001 From: enes Date: Thu, 28 Nov 2024 16:47:10 +0100 Subject: [PATCH 12/14] feat: add filtering, split mods and blog filters --- src/components/Filters/BlogsFilter.tsx | 165 +++++++++++++++++ src/components/Filters/Dropdown.tsx | 25 +++ src/components/Filters/ModsFilter.tsx | 182 +++++++++++++++++++ src/components/Filters/Option.tsx | 16 ++ src/components/Filters/index.tsx | 9 + src/components/ModsFilter.tsx | 235 ------------------------- src/hooks/useFilteredMods.ts | 30 +++- src/pages/blogs/index.tsx | 96 ++++------ src/pages/game.tsx | 8 +- src/pages/mods.tsx | 16 +- src/pages/profile/index.tsx | 18 +- src/pages/search.tsx | 11 +- src/types/blog.ts | 9 + src/types/modsFilter.ts | 7 + src/utils/consts.ts | 6 +- 15 files changed, 510 insertions(+), 323 deletions(-) create mode 100644 src/components/Filters/BlogsFilter.tsx create mode 100644 src/components/Filters/Dropdown.tsx create mode 100644 src/components/Filters/ModsFilter.tsx create mode 100644 src/components/Filters/Option.tsx create mode 100644 src/components/Filters/index.tsx delete mode 100644 src/components/ModsFilter.tsx diff --git a/src/components/Filters/BlogsFilter.tsx b/src/components/Filters/BlogsFilter.tsx new file mode 100644 index 0000000..71b4098 --- /dev/null +++ b/src/components/Filters/BlogsFilter.tsx @@ -0,0 +1,165 @@ +import { useAppSelector, useLocalStorage } from 'hooks' +import React from 'react' +import { + FilterOptions, + ModeratedFilter, + NSFWFilter, + SortBy, + WOTFilterOptions +} from 'types' +import { DEFAULT_FILTER_OPTIONS } from 'utils' +import { Dropdown } from './Dropdown' +import { Option } from './Option' +import { Filter } from '.' + +type Props = { + author?: string | undefined + filterKey?: string | undefined +} + +export const BlogsFilter = React.memo( + ({ author, filterKey = 'filter-blog' }: Props) => { + const userState = useAppSelector((state) => state.user) + const [filterOptions, setFilterOptions] = useLocalStorage( + filterKey, + DEFAULT_FILTER_OPTIONS + ) + + return ( + + {/* sort filter options */} + + {Object.values(SortBy).map((item, index) => ( +
+ setFilterOptions((prev) => ({ + ...prev, + sort: item + })) + } + > + {item} +
+ ))} +
+ + {/* moderation filter options */} + + {Object.values(ModeratedFilter).map((item) => { + if (item === ModeratedFilter.Unmoderated_Fully) { + const isAdmin = + userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB + + const isOwnProfile = + author && userState.auth && userState.user?.pubkey === author + + if (!(isAdmin || isOwnProfile)) return null + } + + return ( + + ) + })} + + + {/* wot filter options */} + Trust: {filterOptions.wot}}> + {Object.values(WOTFilterOptions).map((item, index) => { + // when user is not logged in + if (item === WOTFilterOptions.Site_And_Mine && !userState.auth) { + return null + } + + // when logged in user not admin + if ( + item === WOTFilterOptions.None || + item === WOTFilterOptions.Mine_Only || + item === WOTFilterOptions.Exclude + ) { + const isWoTNpub = + userState.user?.npub === import.meta.env.VITE_SITE_WOT_NPUB + + const isOwnProfile = + author && userState.auth && userState.user?.pubkey === author + + if (!(isWoTNpub || isOwnProfile)) return null + } + + return ( + + ) + })} + + + {/* nsfw filter options */} + + {Object.values(NSFWFilter).map((item, index) => ( + + ))} + + + {/* source filter options */} + + + + +
+ ) + } +) diff --git a/src/components/Filters/Dropdown.tsx b/src/components/Filters/Dropdown.tsx new file mode 100644 index 0000000..1bfb02f --- /dev/null +++ b/src/components/Filters/Dropdown.tsx @@ -0,0 +1,25 @@ +import { PropsWithChildren } from 'react' + +interface DropdownProps { + label: React.ReactNode +} +export const Dropdown = ({ + label, + children +}: PropsWithChildren) => { + return ( +
+
+ +
{children}
+
+
+ ) +} diff --git a/src/components/Filters/ModsFilter.tsx b/src/components/Filters/ModsFilter.tsx new file mode 100644 index 0000000..afa43c4 --- /dev/null +++ b/src/components/Filters/ModsFilter.tsx @@ -0,0 +1,182 @@ +import { useAppSelector, useLocalStorage } from 'hooks' +import React from 'react' +import { + FilterOptions, + SortBy, + ModeratedFilter, + WOTFilterOptions, + NSFWFilter, + RepostFilter +} from 'types' +import { DEFAULT_FILTER_OPTIONS } from 'utils' +import { Filter } from '.' +import { Dropdown } from './Dropdown' +import { Option } from './Option' + +type Props = { + author?: string | undefined + filterKey?: string | undefined +} + +export const ModFilter = React.memo( + ({ author, filterKey = 'filter' }: Props) => { + const userState = useAppSelector((state) => state.user) + const [filterOptions, setFilterOptions] = useLocalStorage( + filterKey, + DEFAULT_FILTER_OPTIONS + ) + + return ( + + {/* sort filter options */} + + {Object.values(SortBy).map((item, index) => ( + + ))} + + + {/* moderation filter options */} + + {Object.values(ModeratedFilter).map((item, index) => { + if (item === ModeratedFilter.Unmoderated_Fully) { + const isAdmin = + userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB + + const isOwnProfile = + author && userState.auth && userState.user?.pubkey === author + + if (!(isAdmin || isOwnProfile)) return null + } + + return ( + + ) + })} + + + {/* wot filter options */} + Trust: {filterOptions.wot}}> + {Object.values(WOTFilterOptions).map((item, index) => { + // when user is not logged in + if (item === WOTFilterOptions.Site_And_Mine && !userState.auth) { + return null + } + + // when logged in user not admin + if ( + item === WOTFilterOptions.None || + item === WOTFilterOptions.Mine_Only || + item === WOTFilterOptions.Exclude + ) { + const isWoTNpub = + userState.user?.npub === import.meta.env.VITE_SITE_WOT_NPUB + + const isOwnProfile = + author && userState.auth && userState.user?.pubkey === author + + if (!(isWoTNpub || isOwnProfile)) return null + } + + return ( + + ) + })} + + + {/* nsfw filter options */} + + {Object.values(NSFWFilter).map((item, index) => ( + + ))} + + + {/* repost filter options */} + + {Object.values(RepostFilter).map((item, index) => ( + + ))} + + + {/* source filter options */} + + + + + + ) + } +) diff --git a/src/components/Filters/Option.tsx b/src/components/Filters/Option.tsx new file mode 100644 index 0000000..1f854e8 --- /dev/null +++ b/src/components/Filters/Option.tsx @@ -0,0 +1,16 @@ +import { PropsWithChildren } from 'react' + +interface OptionProps { + onClick: React.MouseEventHandler +} + +export const Option = ({ + onClick, + children +}: PropsWithChildren) => { + return ( +
+ {children} +
+ ) +} diff --git a/src/components/Filters/index.tsx b/src/components/Filters/index.tsx new file mode 100644 index 0000000..4260f79 --- /dev/null +++ b/src/components/Filters/index.tsx @@ -0,0 +1,9 @@ +import { PropsWithChildren } from 'react' + +export const Filter = ({ children }: PropsWithChildren) => { + return ( +
+
{children}
+
+ ) +} diff --git a/src/components/ModsFilter.tsx b/src/components/ModsFilter.tsx deleted file mode 100644 index 8d733c7..0000000 --- a/src/components/ModsFilter.tsx +++ /dev/null @@ -1,235 +0,0 @@ -import { useAppSelector, useLocalStorage } from 'hooks' -import React from 'react' -import { - FilterOptions, - ModeratedFilter, - NSFWFilter, - SortBy, - WOTFilterOptions -} from 'types' -import { DEFAULT_FILTER_OPTIONS } from 'utils' - -type Props = { - author?: string | undefined - filterKey?: string | undefined -} - -export const ModFilter = React.memo( - ({ author, filterKey = 'filter' }: Props) => { - const userState = useAppSelector((state) => state.user) - const [filterOptions, setFilterOptions] = useLocalStorage( - filterKey, - DEFAULT_FILTER_OPTIONS - ) - - return ( -
-
- {/* sort filter options */} -
-
- - -
- {Object.values(SortBy).map((item, index) => ( -
- setFilterOptions((prev) => ({ - ...prev, - sort: item - })) - } - > - {item} -
- ))} -
-
-
- - {/* moderation filter options */} -
-
- -
- {Object.values(ModeratedFilter).map((item, index) => { - if (item === ModeratedFilter.Unmoderated_Fully) { - const isAdmin = - userState.user?.npub === - import.meta.env.VITE_REPORTING_NPUB - - const isOwnProfile = - author && - userState.auth && - userState.user?.pubkey === author - - if (!(isAdmin || isOwnProfile)) return null - } - - return ( -
- setFilterOptions((prev) => ({ - ...prev, - moderated: item - })) - } - > - {item} -
- ) - })} -
-
-
- - {/* wot filter options */} -
-
- -
- {Object.values(WOTFilterOptions).map((item, index) => { - // when user is not logged in - if ( - item === WOTFilterOptions.Site_And_Mine && - !userState.auth - ) { - return null - } - - // when logged in user not admin - if ( - item === WOTFilterOptions.None || - item === WOTFilterOptions.Mine_Only || - item === WOTFilterOptions.Exclude - ) { - const isWoTNpub = - userState.user?.npub === - import.meta.env.VITE_SITE_WOT_NPUB - - const isOwnProfile = - author && - userState.auth && - userState.user?.pubkey === author - - if (!(isWoTNpub || isOwnProfile)) return null - } - - return ( -
- setFilterOptions((prev) => ({ - ...prev, - wot: item - })) - } - > - {item} -
- ) - })} -
-
-
- - {/* nsfw filter options */} -
-
- -
- {Object.values(NSFWFilter).map((item, index) => ( -
- setFilterOptions((prev) => ({ - ...prev, - nsfw: item - })) - } - > - {item} -
- ))} -
-
-
- - {/* source filter options */} -
-
- -
-
- setFilterOptions((prev) => ({ - ...prev, - source: window.location.host - })) - } - > - Show From: {window.location.host} -
-
- setFilterOptions((prev) => ({ - ...prev, - source: 'Show All' - })) - } - > - Show All -
-
-
-
-
-
- ) - } -) diff --git a/src/hooks/useFilteredMods.ts b/src/hooks/useFilteredMods.ts index 7c16b18..ef7c249 100644 --- a/src/hooks/useFilteredMods.ts +++ b/src/hooks/useFilteredMods.ts @@ -6,6 +6,7 @@ import { ModeratedFilter, MuteLists, NSFWFilter, + RepostFilter, SortBy, WOTFilterOptions } from 'types' @@ -22,6 +23,7 @@ export const useFilteredMods = ( admin: MuteLists user: MuteLists }, + repostList: string[], author?: string | undefined ) => { const { siteWot, siteWotLevel, userWot, userWotLevel } = useAppSelector( @@ -53,6 +55,30 @@ export const useFilteredMods = ( } } + const repostFilter = (mods: ModDetails[]) => { + if (filterOptions.repost !== RepostFilter.Hide_Repost) { + // Add repost tag to mods included in repostList + mods = mods.map((mod) => { + return !mod.repost && repostList.includes(mod.aTag) + ? { ...mod, repost: true } + : mod + }) + } + // Determine the filtering logic based on the Repost filter option + switch (filterOptions.repost) { + case RepostFilter.Hide_Repost: + return mods.filter( + (mod) => !mod.repost && !repostList.includes(mod.aTag) + ) + case RepostFilter.Show_Repost: + return mods + case RepostFilter.Only_Repost: + return mods.filter( + (mod) => mod.repost || repostList.includes(mod.aTag) + ) + } + } + const wotFilter = (mods: ModDetails[]) => { // Determine the filtering logic based on the WOT filter option and user state // when user is not logged in use Site_Only @@ -93,7 +119,7 @@ export const useFilteredMods = ( } let filtered = nsfwFilter(mods) - + filtered = repostFilter(filtered) filtered = wotFilter(filtered) const isAdmin = userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB @@ -135,10 +161,12 @@ export const useFilteredMods = ( filterOptions.moderated, filterOptions.wot, filterOptions.nsfw, + filterOptions.repost, author, mods, muteLists, nsfwList, + repostList, siteWot, siteWotLevel, userWot, diff --git a/src/pages/blogs/index.tsx b/src/pages/blogs/index.tsx index 08d0848..2155b39 100644 --- a/src/pages/blogs/index.tsx +++ b/src/pages/blogs/index.tsx @@ -11,6 +11,9 @@ import '../../styles/styles.css' import { PaginationWithPageNumbers } from 'components/Pagination' import { scrollIntoView } from 'utils' import { LoadingSpinner } from 'components/LoadingSpinner' +import { Filter } from 'components/Filters' +import { Dropdown } from 'components/Filters/Dropdown' +import { Option } from 'components/Filters/Option' export const BlogsPage = () => { const navigation = useNavigation() @@ -126,66 +129,39 @@ export const BlogsPage = () => {
-
-
-
-
- -
- {Object.values(SortBy).map((item, index) => ( -
- setFilterOptions((prev) => ({ - ...prev, - sort: item - })) - } - > - {item} -
- ))} -
-
-
-
-
- -
- {Object.values(NSFWFilter).map((item, index) => ( -
- setFilterOptions((prev) => ({ - ...prev, - nsfw: item - })) - } - > - {item} -
- ))} -
-
-
-
-
+ + + {Object.values(SortBy).map((item, index) => ( + + ))} + + + + {Object.values(NSFWFilter).map((item, index) => ( + + ))} + +
diff --git a/src/pages/game.tsx b/src/pages/game.tsx index 6f82493..da49f87 100644 --- a/src/pages/game.tsx +++ b/src/pages/game.tsx @@ -4,7 +4,7 @@ import { NDKSubscriptionCacheUsage } from '@nostr-dev-kit/ndk' import { ModCard } from 'components/ModCard' -import { ModFilter } from 'components/ModsFilter' +import { ModFilter } from 'components/Filters/ModsFilter' import { PaginationWithPageNumbers } from 'components/Pagination' import { SearchInput } from 'components/SearchInput' import { MAX_MODS_PER_PAGE, T_TAG_VALUE } from 'constants.ts' @@ -20,11 +20,13 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useParams, useSearchParams } from 'react-router-dom' import { FilterOptions, ModDetails } from 'types' import { + CurationSetIdentifiers, DEFAULT_FILTER_OPTIONS, extractModData, isModDataComplete, scrollIntoView } from 'utils' +import { useCuratedSet } from 'hooks/useCuratedSet' export const GamePage = () => { const scrollTargetRef = useRef(null) @@ -33,6 +35,7 @@ export const GamePage = () => { const { ndk } = useNDKContext() const muteLists = useMuteLists() const nsfwList = useNSFWList() + const repostList = useCuratedSet(CurationSetIdentifiers.Repost) const [filterOptions] = useLocalStorage( 'filter', @@ -101,7 +104,8 @@ export const GamePage = () => { userState, filterOptions, nsfwList, - muteLists + muteLists, + repostList ) // Pagination logic diff --git a/src/pages/mods.tsx b/src/pages/mods.tsx index 72c8c7a..cd2ad21 100644 --- a/src/pages/mods.tsx +++ b/src/pages/mods.tsx @@ -1,4 +1,4 @@ -import { ModFilter } from 'components/ModsFilter' +import { ModFilter } from 'components/Filters/ModsFilter' import { Pagination } from 'components/Pagination' import React, { useCallback, useEffect, useRef, useState } from 'react' import { createSearchParams, useNavigate } from 'react-router-dom' @@ -39,6 +39,7 @@ export const ModsPage = () => { ) const muteLists = useMuteLists() const nsfwList = useNSFWList() + const repostList = useCuratedSet(CurationSetIdentifiers.Repost) const [page, setPage] = useState(1) @@ -99,17 +100,10 @@ export const ModsPage = () => { userState, filterOptions, nsfwList, - muteLists + muteLists, + repostList ) - // Add repost tag to mods included in repostList - const repostList = useCuratedSet(CurationSetIdentifiers.Repost) - const filteredModListRepost = filteredModList.map((mod) => { - return !mod.repost && repostList.includes(mod.aTag) - ? { ...mod, repost: true } - : mod - }) - return ( <> {isFetching && } @@ -124,7 +118,7 @@ export const ModsPage = () => {
- {filteredModListRepost.map((mod) => ( + {filteredModList.map((mod) => ( ))}
diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx index 83dfa51..3761a5b 100644 --- a/src/pages/profile/index.tsx +++ b/src/pages/profile/index.tsx @@ -1,7 +1,7 @@ import { NDKFilter, NDKKind } from '@nostr-dev-kit/ndk' import { LoadingSpinner } from 'components/LoadingSpinner' import { ModCard } from 'components/ModCard' -import { ModFilter } from 'components/ModsFilter' +import { ModFilter } from 'components/Filters/ModsFilter' import { Pagination } from 'components/Pagination' import { ProfileSection } from 'components/ProfileSection' import { Tabs } from 'components/Tabs' @@ -39,6 +39,7 @@ import { import { CheckboxField } from 'components/Inputs' import { ProfilePageLoaderResult } from './loader' import { BlogCard } from 'components/BlogCard' +import { BlogsFilter } from 'components/Filters/BlogsFilter' export const ProfilePage = () => { const { @@ -269,16 +270,10 @@ export const ProfilePage = () => { filterOptions, nsfwList, muteLists, + repostList, profilePubkey ) - // Add repost tag to mods included in repostList - const filteredModListRepost = filteredModList.map((mod) => { - return !mod.repost && repostList.includes(mod.aTag) - ? { ...mod, repost: true } - : mod - }) - return (
@@ -429,7 +424,7 @@ export const ProfilePage = () => {
- {filteredModListRepost.map((mod) => ( + {filteredModList.map((mod) => ( ))}
@@ -831,7 +826,10 @@ const ProfileTabBlogs = () => { )} - +
{moderatedAndSortedBlogs.map((b) => ( diff --git a/src/pages/search.tsx b/src/pages/search.tsx index f94be8f..683696f 100644 --- a/src/pages/search.tsx +++ b/src/pages/search.tsx @@ -10,7 +10,7 @@ import { import { ErrorBoundary } from 'components/ErrorBoundary' import { GameCard } from 'components/GameCard' import { ModCard } from 'components/ModCard' -import { ModFilter } from 'components/ModsFilter' +import { ModFilter } from 'components/Filters/ModsFilter' import { Pagination } from 'components/Pagination' import { Profile } from 'components/ProfileSection' import { SearchInput } from 'components/SearchInput' @@ -32,11 +32,13 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' import { useSearchParams } from 'react-router-dom' import { FilterOptions, ModDetails, ModeratedFilter, MuteLists } from 'types' import { + CurationSetIdentifiers, DEFAULT_FILTER_OPTIONS, extractModData, isModDataComplete, scrollIntoView } from 'utils' +import { useCuratedSet } from 'hooks/useCuratedSet' enum SearchKindEnum { Mods = 'Mods', @@ -50,6 +52,7 @@ export const SearchPage = () => { const muteLists = useMuteLists() const nsfwList = useNSFWList() + const repostList = useCuratedSet(CurationSetIdentifiers.Repost) const searchTermRef = useRef(null) const searchKind = @@ -115,6 +118,7 @@ export const SearchPage = () => { filterOptions={filterOptions} muteLists={muteLists} nsfwList={nsfwList} + repostList={repostList} el={scrollTargetRef.current} /> )} @@ -233,6 +237,7 @@ type ModsResultProps = { user: MuteLists } nsfwList: string[] + repostList: string[] el: HTMLElement | null } @@ -241,6 +246,7 @@ const ModsResult = ({ searchTerm, muteLists, nsfwList, + repostList, el }: ModsResultProps) => { const { ndk } = useNDKContext() @@ -313,7 +319,8 @@ const ModsResult = ({ userState, filterOptions, nsfwList, - muteLists + muteLists, + repostList ) const handleNext = () => { diff --git a/src/types/blog.ts b/src/types/blog.ts index 7b76549..075a63c 100644 --- a/src/types/blog.ts +++ b/src/types/blog.ts @@ -1,3 +1,5 @@ +import { SortBy, NSFWFilter, ModeratedFilter } from './modsFilter' + export interface BlogForm { title: string content: string @@ -40,3 +42,10 @@ export interface BlogPageLoaderResult { isAddedToNSFW: boolean isBlocked: boolean } + +export interface BlogsFilterOptions { + sort: SortBy + nsfw: NSFWFilter + source: string + moderated: ModeratedFilter +} diff --git a/src/types/modsFilter.ts b/src/types/modsFilter.ts index b0ae63b..6310f1d 100644 --- a/src/types/modsFilter.ts +++ b/src/types/modsFilter.ts @@ -25,10 +25,17 @@ export enum WOTFilterOptions { Exclude = 'Exclude' } +export enum RepostFilter { + Hide_Repost = 'Hide Repost', + Show_Repost = 'Show Repost', + Only_Repost = 'Only Repost' +} + export interface FilterOptions { sort: SortBy nsfw: NSFWFilter source: string moderated: ModeratedFilter wot: WOTFilterOptions + repost: RepostFilter } diff --git a/src/utils/consts.ts b/src/utils/consts.ts index 611a3a8..38da842 100644 --- a/src/utils/consts.ts +++ b/src/utils/consts.ts @@ -3,7 +3,8 @@ import { SortBy, NSFWFilter, ModeratedFilter, - WOTFilterOptions + WOTFilterOptions, + RepostFilter } from 'types' export const DEFAULT_FILTER_OPTIONS: FilterOptions = { @@ -11,5 +12,6 @@ export const DEFAULT_FILTER_OPTIONS: FilterOptions = { nsfw: NSFWFilter.Hide_NSFW, source: window.location.host, moderated: ModeratedFilter.Moderated, - wot: WOTFilterOptions.Site_Only + wot: WOTFilterOptions.Site_Only, + repost: RepostFilter.Show_Repost } -- 2.34.1 From f61c32c16a209406f69bbbd51a1d967fdfdd0a3e Mon Sep 17 00:00:00 2001 From: enes Date: Thu, 28 Nov 2024 17:13:53 +0100 Subject: [PATCH 13/14] fix: comments --- src/pages/profile/loader.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/profile/loader.ts b/src/pages/profile/loader.ts index b9c13e9..f5bc1b6 100644 --- a/src/pages/profile/loader.ts +++ b/src/pages/profile/loader.ts @@ -123,7 +123,7 @@ export const profileRouteLoader = ) } - // Check the profile event result + // Check the mutelist event result const muteListResult = settled[1] if (muteListResult.status === 'fulfilled' && muteListResult.value) { result.muteLists = muteListResult.value @@ -139,7 +139,7 @@ export const profileRouteLoader = ) } - // Check the profile event result + // Check the nsfwlist event result const nsfwListResult = settled[2] if (nsfwListResult.status === 'fulfilled' && nsfwListResult.value) { result.nsfwList = nsfwListResult.value @@ -152,7 +152,7 @@ export const profileRouteLoader = ) } - // Check the profile event result + // Check the repostlist event result const repostListResult = settled[3] if (repostListResult.status === 'fulfilled' && repostListResult.value) { result.repostList = repostListResult.value -- 2.34.1 From 6246dece843b42d92be2c7d25fa3748c054c4fd2 Mon Sep 17 00:00:00 2001 From: enes Date: Thu, 28 Nov 2024 17:21:35 +0100 Subject: [PATCH 14/14] refactor(mods): use loader to preload lists --- src/pages/home.tsx | 4 +- src/pages/{mods.tsx => mods/index.tsx} | 43 +++++++------- src/pages/mods/loader.ts | 77 ++++++++++++++++++++++++++ src/routes/index.tsx | 4 +- 4 files changed, 101 insertions(+), 27 deletions(-) rename src/pages/{mods.tsx => mods/index.tsx} (83%) create mode 100644 src/pages/mods/loader.ts diff --git a/src/pages/home.tsx b/src/pages/home.tsx index 78f722c..ef3d845 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -430,9 +430,7 @@ const DisplayLatestBlogs = () => { return (
- {navigation.state !== 'idle' && ( - - )} + {navigation.state !== 'idle' && }

Blog Posts

diff --git a/src/pages/mods.tsx b/src/pages/mods/index.tsx similarity index 83% rename from src/pages/mods.tsx rename to src/pages/mods/index.tsx index cd2ad21..abd9870 100644 --- a/src/pages/mods.tsx +++ b/src/pages/mods/index.tsx @@ -1,34 +1,34 @@ import { ModFilter } from 'components/Filters/ModsFilter' import { Pagination } from 'components/Pagination' import React, { useCallback, useEffect, useRef, useState } from 'react' -import { createSearchParams, useNavigate } from 'react-router-dom' -import { LoadingSpinner } from '../components/LoadingSpinner' -import { ModCard } from '../components/ModCard' -import { MOD_FILTER_LIMIT } from '../constants' +import { + createSearchParams, + useLoaderData, + useNavigate +} from 'react-router-dom' +import { LoadingSpinner } from '../../components/LoadingSpinner' +import { ModCard } from '../../components/ModCard' +import { MOD_FILTER_LIMIT } from '../../constants' import { useAppSelector, useFilteredMods, useLocalStorage, - useMuteLists, - useNDKContext, - useNSFWList -} from '../hooks' -import { appRoutes } from '../routes' -import '../styles/filters.css' -import '../styles/pagination.css' -import '../styles/search.css' -import '../styles/styles.css' -import { FilterOptions, ModDetails } from '../types' -import { - CurationSetIdentifiers, - DEFAULT_FILTER_OPTIONS, - scrollIntoView -} from 'utils' + useNDKContext +} from '../../hooks' +import { appRoutes } from '../../routes' +import '../../styles/filters.css' +import '../../styles/pagination.css' +import '../../styles/search.css' +import '../../styles/styles.css' +import { FilterOptions, ModDetails } from '../../types' +import { DEFAULT_FILTER_OPTIONS, scrollIntoView } from 'utils' import { SearchInput } from 'components/SearchInput' -import { useCuratedSet } from 'hooks/useCuratedSet' +import { ModsPageLoaderResult } from './loader' export const ModsPage = () => { const scrollTargetRef = useRef(null) + const { repostList, muteLists, nsfwList } = + useLoaderData() as ModsPageLoaderResult const { fetchMods } = useNDKContext() const [isFetching, setIsFetching] = useState(false) const [mods, setMods] = useState([]) @@ -37,9 +37,6 @@ export const ModsPage = () => { 'filter', DEFAULT_FILTER_OPTIONS ) - const muteLists = useMuteLists() - const nsfwList = useNSFWList() - const repostList = useCuratedSet(CurationSetIdentifiers.Repost) const [page, setPage] = useState(1) diff --git a/src/pages/mods/loader.ts b/src/pages/mods/loader.ts new file mode 100644 index 0000000..24fc3a4 --- /dev/null +++ b/src/pages/mods/loader.ts @@ -0,0 +1,77 @@ +import { NDKContextType } from 'contexts/NDKContext' +import { store } from 'store' +import { MuteLists } from 'types' +import { getReportingSet, CurationSetIdentifiers, log, LogType } from 'utils' + +export interface ModsPageLoaderResult { + muteLists: { + admin: MuteLists + user: MuteLists + } + nsfwList: string[] + repostList: string[] +} + +export const modsRouteLoader = (ndkContext: NDKContextType) => async () => { + // Empty result + const result: ModsPageLoaderResult = { + muteLists: { + admin: { + authors: [], + replaceableEvents: [] + }, + user: { + authors: [], + replaceableEvents: [] + } + }, + nsfwList: [], + repostList: [] + } + + // Get the current state + const userState = store.getState().user + + // Check if current user is logged in + let userPubkey: string | undefined + if (userState.auth && userState.user?.pubkey) { + userPubkey = userState.user.pubkey as string + } + + const settled = await Promise.allSettled([ + ndkContext.getMuteLists(userPubkey), + getReportingSet(CurationSetIdentifiers.NSFW, ndkContext), + getReportingSet(CurationSetIdentifiers.Repost, ndkContext) + ]) + + // Check the mutelist event result + const muteListResult = settled[0] + if (muteListResult.status === 'fulfilled' && muteListResult.value) { + result.muteLists = muteListResult.value + } else if (muteListResult.status === 'rejected') { + log(true, LogType.Error, 'Failed to fetch mutelist.', muteListResult.reason) + } + + // Check the nsfwlist event result + const nsfwListResult = settled[1] + if (nsfwListResult.status === 'fulfilled' && nsfwListResult.value) { + result.nsfwList = nsfwListResult.value + } else if (nsfwListResult.status === 'rejected') { + log(true, LogType.Error, 'Failed to fetch nsfwlist.', nsfwListResult.reason) + } + + // Check the repostlist event result + const repostListResult = settled[2] + if (repostListResult.status === 'fulfilled' && repostListResult.value) { + result.repostList = repostListResult.value + } else if (repostListResult.status === 'rejected') { + log( + true, + LogType.Error, + 'Failed to fetch repost list.', + repostListResult.reason + ) + } + + return result +} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 04d8fb1..ffa0bef 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -7,6 +7,7 @@ import { GamesPage } from '../pages/games' import { HomePage } from '../pages/home' import { ModsPage } from '../pages/mods' import { ModPage } from '../pages/mod' +import { modsRouteLoader } from '../pages/mods/loader' import { modRouteLoader } from '../pages/mod/loader' import { modRouteAction } from '../pages/mod/action' import { SubmitModPage } from '../pages/submitMod' @@ -87,7 +88,8 @@ export const routerWithNdkContext = (context: NDKContextType) => }, { path: appRoutes.mods, - element: + element: , + loader: modsRouteLoader(context) }, { path: appRoutes.mod, -- 2.34.1