feat: blogs #118
@ -113,7 +113,8 @@ export const REACTIONS = {
|
|||||||
|
|
||||||
export const MAX_MODS_PER_PAGE = 10
|
export const MAX_MODS_PER_PAGE = 10
|
||||||
export const MAX_GAMES_PER_PAGE = 10
|
export const MAX_GAMES_PER_PAGE = 10
|
||||||
|
|
||||||
// todo: add game and mod fallback image here
|
// todo: add game and mod fallback image here
|
||||||
export const FALLBACK_PROFILE_IMAGE =
|
export const FALLBACK_PROFILE_IMAGE =
|
||||||
'https://image.nostr.build/a305f4b43f74af3c6dcda42e6a798105a56ac1e3e7b74d7bef171896b3ba7520.png'
|
'https://image.nostr.build/a305f4b43f74af3c6dcda42e6a798105a56ac1e3e7b74d7bef171896b3ba7520.png'
|
||||||
|
|
||||||
|
export const PROFILE_BLOG_FILTER_LIMIT = 20
|
||||||
|
@ -5,7 +5,7 @@ import { ModFilter } from 'components/ModsFilter'
|
|||||||
import { Pagination } from 'components/Pagination'
|
import { Pagination } from 'components/Pagination'
|
||||||
import { ProfileSection } from 'components/ProfileSection'
|
import { ProfileSection } from 'components/ProfileSection'
|
||||||
import { Tabs } from 'components/Tabs'
|
import { Tabs } from 'components/Tabs'
|
||||||
import { MOD_FILTER_LIMIT } from '../constants'
|
import { MOD_FILTER_LIMIT, PROFILE_BLOG_FILTER_LIMIT } from '../../constants'
|
||||||
import {
|
import {
|
||||||
useAppSelector,
|
useAppSelector,
|
||||||
useFilteredMods,
|
useFilteredMods,
|
||||||
@ -14,15 +14,23 @@ import {
|
|||||||
useNDKContext,
|
useNDKContext,
|
||||||
useNSFWList
|
useNSFWList
|
||||||
} from 'hooks'
|
} from 'hooks'
|
||||||
import { nip19, UnsignedEvent } from 'nostr-tools'
|
import { kinds, nip19, UnsignedEvent } from 'nostr-tools'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useParams, Navigate, Link } from 'react-router-dom'
|
import { useParams, Navigate, Link, useLoaderData } from 'react-router-dom'
|
||||||
import { toast } from 'react-toastify'
|
import { toast } from 'react-toastify'
|
||||||
import { appRoutes, getProfilePageRoute } from 'routes'
|
import { appRoutes, getProfilePageRoute } from 'routes'
|
||||||
import { FilterOptions, ModDetails, UserRelaysType } from 'types'
|
import {
|
||||||
|
BlogCardDetails,
|
||||||
|
FilterOptions,
|
||||||
|
ModDetails,
|
||||||
|
NSFWFilter,
|
||||||
|
SortBy,
|
||||||
|
UserRelaysType
|
||||||
|
} from 'types'
|
||||||
import {
|
import {
|
||||||
copyTextToClipboard,
|
copyTextToClipboard,
|
||||||
DEFAULT_FILTER_OPTIONS,
|
DEFAULT_FILTER_OPTIONS,
|
||||||
|
extractBlogCardDetails,
|
||||||
log,
|
log,
|
||||||
LogType,
|
LogType,
|
||||||
now,
|
now,
|
||||||
@ -32,9 +40,15 @@ import {
|
|||||||
signAndPublish
|
signAndPublish
|
||||||
} from 'utils'
|
} from 'utils'
|
||||||
import { CheckboxField } from 'components/Inputs'
|
import { CheckboxField } from 'components/Inputs'
|
||||||
import { useProfile } from 'hooks/useProfile'
|
import { ProfilePageLoaderResult } from './loader'
|
||||||
|
import { BlogCard } from 'components/BlogCard'
|
||||||
|
|
||||||
export const ProfilePage = () => {
|
export const ProfilePage = () => {
|
||||||
|
const {
|
||||||
|
profile,
|
||||||
|
isBlocked: _isBlocked,
|
||||||
|
isOwnProfile
|
||||||
|
} = useLoaderData() as ProfilePageLoaderResult
|
||||||
// Try to decode nprofile parameter
|
// Try to decode nprofile parameter
|
||||||
const { nprofile } = useParams()
|
const { nprofile } = useParams()
|
||||||
let profilePubkey: string | undefined
|
let profilePubkey: string | undefined
|
||||||
@ -51,46 +65,14 @@ export const ProfilePage = () => {
|
|||||||
const scrollTargetRef = useRef<HTMLDivElement>(null)
|
const scrollTargetRef = useRef<HTMLDivElement>(null)
|
||||||
const { ndk, publish, fetchEventFromUserRelays, fetchMods } = useNDKContext()
|
const { ndk, publish, fetchEventFromUserRelays, fetchMods } = useNDKContext()
|
||||||
const userState = useAppSelector((state) => state.user)
|
const userState = useAppSelector((state) => state.user)
|
||||||
const isOwnProfile =
|
|
||||||
userState.auth && userState.user?.pubkey === profilePubkey
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
|
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
|
||||||
|
|
||||||
const profile = useProfile(profilePubkey)
|
|
||||||
|
|
||||||
const displayName =
|
const displayName =
|
||||||
profile?.displayName || profile?.name || '[name not set up]'
|
profile?.displayName || profile?.name || '[name not set up]'
|
||||||
const [showReportPopUp, setShowReportPopUp] = useState(false)
|
const [showReportPopUp, setShowReportPopUp] = useState(false)
|
||||||
|
|
||||||
const [isBlocked, setIsBlocked] = useState(false)
|
const [isBlocked, setIsBlocked] = useState(_isBlocked)
|
||||||
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 handleBlock = async () => {
|
const handleBlock = async () => {
|
||||||
if (!profilePubkey) {
|
if (!profilePubkey) {
|
||||||
toast.error(`Something went wrong. Unable to find reported user's pubkey`)
|
toast.error(`Something went wrong. Unable to find reported user's pubkey`)
|
||||||
@ -482,7 +464,7 @@ export const ProfilePage = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 1 && <>WIP</>}
|
{tab === 1 && <ProfileTabBlogs />}
|
||||||
{tab === 2 && <>WIP</>}
|
{tab === 2 && <>WIP</>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -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<Partial<BlogCardDetails>[]>([])
|
||||||
|
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 && <LoadingSpinner desc={'Fetching blogs...'} />}
|
||||||
|
|
||||||
|
<ModFilter filterKey={'filter-blog'} author={profile?.pubkey as string} />
|
||||||
|
|
||||||
|
<div className='IBMSMList IBMSMListAlt'>
|
||||||
|
{sortedBlogs.map((b) => (
|
||||||
|
<BlogCard key={b.id} {...b} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!(page === 1 && !hasMore) && (
|
||||||
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
disabledNext={!hasMore}
|
||||||
|
handlePrev={handlePrev}
|
||||||
|
handleNext={handleNext}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
91
src/pages/profile/loader.ts
Normal file
91
src/pages/profile/loader.ts
Normal file
@ -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
|
||||||
|
}
|
@ -8,6 +8,7 @@ import { HomePage } from '../pages/home'
|
|||||||
import { ModPage } from '../pages/mod'
|
import { ModPage } from '../pages/mod'
|
||||||
import { ModsPage } from '../pages/mods'
|
import { ModsPage } from '../pages/mods'
|
||||||
import { ProfilePage } from '../pages/profile'
|
import { ProfilePage } from '../pages/profile'
|
||||||
|
import { profileRouteLoader } from 'pages/profile/loader'
|
||||||
import { SettingsPage } from '../pages/settings'
|
import { SettingsPage } from '../pages/settings'
|
||||||
import { SubmitModPage } from '../pages/submitMod'
|
import { SubmitModPage } from '../pages/submitMod'
|
||||||
import { GamePage } from '../pages/game'
|
import { GamePage } from '../pages/game'
|
||||||
@ -18,9 +19,9 @@ import { NotificationsPage } from '../pages/notifications'
|
|||||||
import { WritePage } from '../pages/write'
|
import { WritePage } from '../pages/write'
|
||||||
import { writeRouteAction } from '../pages/write/action'
|
import { writeRouteAction } from '../pages/write/action'
|
||||||
import { BlogsPage } from 'pages/blogs'
|
import { BlogsPage } from 'pages/blogs'
|
||||||
|
import { blogsRouteLoader } from 'pages/blogs/loader'
|
||||||
import { BlogPage } from 'pages/blog'
|
import { BlogPage } from 'pages/blog'
|
||||||
import { blogRouteLoader } from 'pages/blog/loader'
|
import { blogRouteLoader } from 'pages/blog/loader'
|
||||||
import { blogsRouteLoader } from 'pages/blogs/loader'
|
|
||||||
|
|
||||||
export const appRoutes = {
|
export const appRoutes = {
|
||||||
index: '/',
|
index: '/',
|
||||||
@ -134,7 +135,8 @@ export const routerWithNdkContext = (context: NDKContextType) =>
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: appRoutes.profile,
|
path: appRoutes.profile,
|
||||||
element: <ProfilePage />
|
element: <ProfilePage />,
|
||||||
|
loader: profileRouteLoader(context)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
element: <FeedLayout />,
|
element: <FeedLayout />,
|
||||||
|
Loading…
Reference in New Issue
Block a user