diff --git a/src/constants.ts b/src/constants.ts index 8b91a18..d4a56d8 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -113,7 +113,8 @@ export const REACTIONS = { export const MAX_MODS_PER_PAGE = 10 export const MAX_GAMES_PER_PAGE = 10 - // todo: add game and mod fallback image here export const FALLBACK_PROFILE_IMAGE = 'https://image.nostr.build/a305f4b43f74af3c6dcda42e6a798105a56ac1e3e7b74d7bef171896b3ba7520.png' + +export const PROFILE_BLOG_FILTER_LIMIT = 20 diff --git a/src/pages/profile.tsx b/src/pages/profile/index.tsx similarity index 83% rename from src/pages/profile.tsx rename to src/pages/profile/index.tsx index 84e2293..6f1c305 100644 --- a/src/pages/profile.tsx +++ b/src/pages/profile/index.tsx @@ -5,7 +5,7 @@ import { ModFilter } from 'components/ModsFilter' import { Pagination } from 'components/Pagination' import { ProfileSection } from 'components/ProfileSection' import { Tabs } from 'components/Tabs' -import { MOD_FILTER_LIMIT } from '../constants' +import { MOD_FILTER_LIMIT, PROFILE_BLOG_FILTER_LIMIT } from '../../constants' import { useAppSelector, useFilteredMods, @@ -14,15 +14,23 @@ import { useNDKContext, useNSFWList } from 'hooks' -import { nip19, UnsignedEvent } from 'nostr-tools' -import { useCallback, useEffect, useRef, useState } from 'react' -import { useParams, Navigate, Link } from 'react-router-dom' +import { kinds, nip19, UnsignedEvent } from 'nostr-tools' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useParams, Navigate, Link, useLoaderData } from 'react-router-dom' import { toast } from 'react-toastify' import { appRoutes, getProfilePageRoute } from 'routes' -import { FilterOptions, ModDetails, UserRelaysType } from 'types' +import { + BlogCardDetails, + FilterOptions, + ModDetails, + NSFWFilter, + SortBy, + UserRelaysType +} from 'types' import { copyTextToClipboard, DEFAULT_FILTER_OPTIONS, + extractBlogCardDetails, log, LogType, now, @@ -32,9 +40,15 @@ import { signAndPublish } from 'utils' import { CheckboxField } from 'components/Inputs' -import { useProfile } from 'hooks/useProfile' +import { ProfilePageLoaderResult } from './loader' +import { BlogCard } from 'components/BlogCard' export const ProfilePage = () => { + const { + profile, + isBlocked: _isBlocked, + isOwnProfile + } = useLoaderData() as ProfilePageLoaderResult // Try to decode nprofile parameter const { nprofile } = useParams() let profilePubkey: string | undefined @@ -51,46 +65,14 @@ export const ProfilePage = () => { const scrollTargetRef = useRef(null) const { ndk, publish, fetchEventFromUserRelays, fetchMods } = useNDKContext() const userState = useAppSelector((state) => state.user) - const isOwnProfile = - userState.auth && userState.user?.pubkey === profilePubkey const [isLoading, setIsLoading] = useState(false) const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') - const profile = useProfile(profilePubkey) - const displayName = profile?.displayName || profile?.name || '[name not set up]' const [showReportPopUp, setShowReportPopUp] = useState(false) - const [isBlocked, setIsBlocked] = useState(false) - useEffect(() => { - if (userState.auth && userState.user?.pubkey) { - const userHexKey = userState.user.pubkey as string - - const muteListFilter: NDKFilter = { - kinds: [NDKKind.MuteList], - authors: [userHexKey] - } - - fetchEventFromUserRelays( - muteListFilter, - userHexKey, - UserRelaysType.Write - ).then((event) => { - if (event) { - // get a list of tags - const tags = event.tags - const blocked = - tags.findIndex( - (item) => item[0] === 'p' && item[1] === profilePubkey - ) !== -1 - - setIsBlocked(blocked) - } - }) - } - }, [userState, profilePubkey, fetchEventFromUserRelays]) - + const [isBlocked, setIsBlocked] = useState(_isBlocked) const handleBlock = async () => { if (!profilePubkey) { toast.error(`Something went wrong. Unable to find reported user's pubkey`) @@ -482,7 +464,7 @@ export const ProfilePage = () => { )} - {tab === 1 && <>WIP} + {tab === 1 && } {tab === 2 && <>WIP} @@ -703,3 +685,135 @@ const ReportUserPopup = ({ ) } + +const ProfileTabBlogs = () => { + const { profile } = useLoaderData() as ProfilePageLoaderResult + const { fetchEvents } = useNDKContext() + const [filterOptions] = useLocalStorage('filter-blog', DEFAULT_FILTER_OPTIONS) + const [isLoading, setIsLoading] = useState(true) + const blogfilter: NDKFilter = useMemo(() => { + const filter: NDKFilter = { + authors: [profile?.pubkey as string], + kinds: [kinds.LongFormArticle] + } + + const host = window.location.host + if (filterOptions.source === host) { + filter['#r'] = [host] + } + + if (filterOptions.nsfw !== NSFWFilter.Show_NSFW) { + filter['#nsfw'] = [ + (filterOptions.nsfw === NSFWFilter.Only_NSFW).toString() + ] + } + + return filter + }, [filterOptions.nsfw, filterOptions.source, profile?.pubkey]) + + const [page, setPage] = useState(1) + const [hasMore, setHasMore] = useState(false) + const [blogs, setBlogs] = useState[]>([]) + useEffect(() => { + if (profile) { + // Initial blog fetch, go beyond limit to check for next + const filter: NDKFilter = { + ...blogfilter, + limit: PROFILE_BLOG_FILTER_LIMIT + 1 + } + fetchEvents(filter) + .then((events) => { + setBlogs(events.map(extractBlogCardDetails).filter((e) => e.naddr)) + setHasMore(events.length > PROFILE_BLOG_FILTER_LIMIT) + }) + .finally(() => { + setIsLoading(false) + }) + } + }, [blogfilter, fetchEvents, profile]) + + const handleNext = useCallback(() => { + if (isLoading) return + + const last = blogs.length > 0 ? blogs[blogs.length - 1] : undefined + if (last?.published_at) { + const until = last?.published_at - 1 + const nextFilter = { + ...blogfilter, + limit: PROFILE_BLOG_FILTER_LIMIT + 1, + until + } + setIsLoading(true) + fetchEvents(nextFilter) + .then((events) => { + const nextBlogs = events.map(extractBlogCardDetails) + setHasMore(nextBlogs.length > PROFILE_BLOG_FILTER_LIMIT) + setPage((prev) => prev + 1) + setBlogs( + nextBlogs.slice(0, PROFILE_BLOG_FILTER_LIMIT).filter((e) => e.naddr) + ) + }) + .finally(() => setIsLoading(false)) + } + }, [blogfilter, blogs, fetchEvents, isLoading]) + + const handlePrev = useCallback(() => { + if (isLoading) return + + const first = blogs.length > 0 ? blogs[0] : undefined + if (first?.published_at) { + const since = first.published_at + 1 + const prevFilter = { + ...blogfilter, + limit: PROFILE_BLOG_FILTER_LIMIT, + since + } + setIsLoading(true) + fetchEvents(prevFilter) + .then((events) => { + setHasMore(true) + setPage((prev) => prev - 1) + setBlogs(events.map(extractBlogCardDetails).filter((e) => e.naddr)) + }) + .finally(() => setIsLoading(false)) + } + }, [blogfilter, blogs, fetchEvents, isLoading]) + + const sortedBlogs = useMemo(() => { + const sorted = blogs || [] + if (filterOptions.sort === SortBy.Latest) { + sorted.sort((a, b) => + a.published_at && b.published_at ? b.published_at - a.published_at : 0 + ) + } else if (filterOptions.sort === SortBy.Oldest) { + sorted.sort((a, b) => + a.published_at && b.published_at ? a.published_at - b.published_at : 0 + ) + } + + return sorted + }, [blogs, filterOptions.sort]) + + return ( + <> + {isLoading && } + + + +
+ {sortedBlogs.map((b) => ( + + ))} +
+ + {!(page === 1 && !hasMore) && ( + + )} + + ) +} diff --git a/src/pages/profile/loader.ts b/src/pages/profile/loader.ts new file mode 100644 index 0000000..3c80254 --- /dev/null +++ b/src/pages/profile/loader.ts @@ -0,0 +1,91 @@ +import { NDKFilter, NDKKind } from '@nostr-dev-kit/ndk' +import { NDKContextType } from 'contexts/NDKContext' +import { nip19 } from 'nostr-tools' +import { LoaderFunctionArgs, redirect } from 'react-router-dom' +import { appRoutes, getProfilePageRoute } from 'routes' +import { store } from 'store' +import { UserProfile, UserRelaysType } from 'types' +import { log, LogType } from 'utils' + +export interface ProfilePageLoaderResult { + profile: UserProfile + isBlocked: boolean + isOwnProfile: boolean +} + +export const profileRouteLoader = + (ndkContext: NDKContextType) => + async ({ params }: LoaderFunctionArgs) => { + let profileRoute = appRoutes.home + + // Try to decode nprofile parameter + const { nprofile } = params + let profilePubkey: string | undefined + try { + const value = nprofile + ? nip19.decode(nprofile as `nprofile1${string}`) + : undefined + profilePubkey = value?.data.pubkey + } catch (error) { + // Silently ignore and redirect to home or logged in user + log(true, LogType.Error, 'Failed to decode nprofile.', error) + } + + // Get the current state + const userState = store.getState().user + + // Redirect route + // Redirect home if user is not logged in or profile naddr is missing + if (!profilePubkey && userState.auth && userState.user?.pubkey) { + // Redirect to user's profile is no profile is linked + const userHexKey = userState.user.pubkey as string + + if (userHexKey) { + profileRoute = getProfilePageRoute( + nip19.nprofileEncode({ + pubkey: userHexKey + }) + ) + } + } + + if (!profilePubkey) return redirect(profileRoute) + + const result: ProfilePageLoaderResult = { + profile: {}, + isBlocked: false, + isOwnProfile: false + } + + result.profile = await ndkContext.findMetadata(profilePubkey) + + // Check if user the user is logged in + if (userState.auth && userState.user?.pubkey) { + result.isOwnProfile = userState.user.pubkey === profilePubkey + + const userHexKey = userState.user.pubkey as string + + // Check if user has blocked this profile + const muteListFilter: NDKFilter = { + kinds: [NDKKind.MuteList], + authors: [userHexKey] + } + const muteList = await ndkContext.fetchEventFromUserRelays( + muteListFilter, + userHexKey, + UserRelaysType.Write + ) + if (muteList) { + // get a list of tags + const tags = muteList.tags + const blocked = + tags.findIndex( + (item) => item[0] === 'p' && item[1] === profilePubkey + ) !== -1 + + result.isBlocked = blocked + } + } + + return result + } diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 6c7e88a..bab9915 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -8,6 +8,7 @@ import { HomePage } from '../pages/home' import { ModPage } from '../pages/mod' import { ModsPage } from '../pages/mods' 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' @@ -18,9 +19,9 @@ 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 { blogsRouteLoader } from 'pages/blogs/loader' export const appRoutes = { index: '/', @@ -134,7 +135,8 @@ export const routerWithNdkContext = (context: NDKContextType) => }, { path: appRoutes.profile, - element: + element: , + loader: profileRouteLoader(context) }, { element: ,