feat(feed): mods feed

This commit is contained in:
en 2025-02-04 17:23:11 +01:00
parent 2053e22753
commit 31d9a2b258
8 changed files with 476 additions and 2 deletions

View File

@ -0,0 +1,83 @@
import React from 'react'
import { PropsWithChildren } from 'react'
import { Filter } from '.'
import { FilterOptions, SortBy } from 'types'
import { Dropdown } from './Dropdown'
import { Option } from './Option'
import { DEFAULT_FILTER_OPTIONS } from 'utils'
import { useLocalStorage } from 'hooks'
import { NsfwFilterOptions } from './NsfwFilterOptions'
interface FeedFilterProps {
tab: number
}
export const FeedFilter = React.memo(
({ tab, children }: PropsWithChildren<FeedFilterProps>) => {
const filterKey = `filter-feed-${tab}`
const [filterOptions, setFilterOptions] = useLocalStorage<FilterOptions>(
filterKey,
DEFAULT_FILTER_OPTIONS
)
return (
<Filter>
{/* sort filter options */}
{/* show only if not posts tabs */}
{tab !== 2 && (
<Dropdown label={filterOptions.sort}>
{Object.values(SortBy).map((item, index) => (
<Option
key={`sortByItem-${index}`}
onClick={() =>
setFilterOptions((prev) => ({
...prev,
sort: item
}))
}
>
{item}
</Option>
))}
</Dropdown>
)}
{/* nsfw filter options */}
<Dropdown label={filterOptions.nsfw}>
<NsfwFilterOptions filterKey={filterKey} />
</Dropdown>
{/* source filter options */}
<Dropdown
label={
filterOptions.source === window.location.host
? `Show From: ${filterOptions.source}`
: 'Show All'
}
>
<Option
onClick={() =>
setFilterOptions((prev) => ({
...prev,
source: window.location.host
}))
}
>
Show From: {window.location.host}
</Option>
<Option
onClick={() =>
setFilterOptions((prev) => ({
...prev,
source: 'Show All'
}))
}
>
Show All
</Option>
</Dropdown>
{children}
</Filter>
)
}
)

View File

@ -0,0 +1,16 @@
import { useAppSelector, useLocalStorage } from 'hooks'
import { FilterOptions } from 'types'
import { DEFAULT_FILTER_OPTIONS } from 'utils'
export const FeedTabBlogs = () => {
const userState = useAppSelector((state) => state.user)
const userPubkey = userState.user?.pubkey as string | undefined
const filterKey = 'filter-feed-1'
const [filterOptions] = useLocalStorage<FilterOptions>(filterKey, {
...DEFAULT_FILTER_OPTIONS
})
if (!userPubkey) return null
return <></>
}

View File

@ -0,0 +1,226 @@
import { useAppSelector, useLocalStorage, useNDKContext } from 'hooks'
import { useEffect, useMemo, useState } from 'react'
import { useLoaderData } from 'react-router-dom'
import {
FilterOptions,
ModDetails,
NSFWFilter,
RepostFilter,
SortBy
} from 'types'
import {
constructModListFromEvents,
DEFAULT_FILTER_OPTIONS,
orderEventsChronologically
} from 'utils'
import { FeedPageLoaderResult } from './loader'
import {
NDKFilter,
NDKKind,
NDKSubscriptionCacheUsage
} from '@nostr-dev-kit/ndk'
import { LoadingSpinner } from 'components/LoadingSpinner'
import { ModCard } from 'components/ModCard'
export const FeedTabMods = () => {
const { muteLists, nsfwList, repostList, followList } =
useLoaderData() as FeedPageLoaderResult
const userState = useAppSelector((state) => state.user)
const userPubkey = userState.user?.pubkey as string | undefined
const filterKey = 'filter-feed-0'
const [filterOptions] = useLocalStorage<FilterOptions>(filterKey, {
...DEFAULT_FILTER_OPTIONS
})
const { ndk } = useNDKContext()
const [mods, setMods] = useState<ModDetails[]>([])
const [isFetching, setIsFetching] = useState(false)
const [isLoadMoreVisible, setIsLoadMoreVisible] = useState(true)
const [showing, setShowing] = useState(10)
const handleLoadMore = () => {
setShowing((prev) => prev + 10)
const lastMod = mods[mods.length - 1]
const filter: NDKFilter = {
authors: [...followList],
kinds: [NDKKind.Classified],
limit: 20
}
if (filterOptions.source === window.location.host) {
filter['#r'] = [window.location.host]
}
if (filterOptions.sort === SortBy.Latest) {
filter.until = lastMod.published_at
} else if (filterOptions.sort === SortBy.Oldest) {
filter.since = lastMod.published_at
}
setIsFetching(true)
ndk
.fetchEvents(filter, {
closeOnEose: true,
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL
})
.then((ndkEventSet) => {
const ndkEvents = Array.from(ndkEventSet)
orderEventsChronologically(
ndkEvents,
filterOptions.sort === SortBy.Latest
)
setMods((prevMods) => {
const newMods = constructModListFromEvents(ndkEvents)
const combinedMods = [...prevMods, ...newMods]
const uniqueMods = Array.from(
new Set(combinedMods.map((mod) => mod.id))
)
.map((id) => combinedMods.find((mod) => mod.id === id))
.filter((mod): mod is ModDetails => mod !== undefined)
if (newMods.length < 20) {
setIsLoadMoreVisible(false)
}
return uniqueMods
})
})
.finally(() => {
setIsFetching(false)
})
}
useEffect(() => {
setIsFetching(true)
const filter: NDKFilter = {
authors: [...followList],
kinds: [NDKKind.Classified],
limit: 50
}
// If the source matches the current window location, add a filter condition
if (filterOptions.source === window.location.host) {
filter['#r'] = [window.location.host]
}
ndk
.fetchEvents(filter, {
closeOnEose: true,
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL
})
.then((ndkEventSet) => {
const ndkEvents = Array.from(ndkEventSet)
setMods(constructModListFromEvents(ndkEvents))
})
.finally(() => {
setIsFetching(false)
})
}, [filterOptions.source, followList, ndk])
const filteredModList = useMemo(() => {
const nsfwFilter = (mods: ModDetails[]) => {
// Add nsfw tag to mods included in nsfwList
if (filterOptions.nsfw !== NSFWFilter.Hide_NSFW) {
mods = mods.map((mod) => {
return !mod.nsfw && nsfwList.includes(mod.aTag)
? { ...mod, nsfw: true }
: mod
})
}
// Determine the filtering logic based on the NSFW filter option
switch (filterOptions.nsfw) {
case NSFWFilter.Hide_NSFW:
// If 'Hide_NSFW' is selected, filter out NSFW mods
return mods.filter((mod) => !mod.nsfw && !nsfwList.includes(mod.aTag))
case NSFWFilter.Show_NSFW:
// If 'Show_NSFW' is selected, return all mods (no filtering)
return mods
case NSFWFilter.Only_NSFW:
// If 'Only_NSFW' is selected, filter to show only NSFW mods
return mods.filter((mod) => mod.nsfw || nsfwList.includes(mod.aTag))
}
}
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)
)
}
}
let filtered = nsfwFilter(mods)
filtered = repostFilter(filtered)
// Filter out mods from muted authors and replaceable events
filtered = filtered.filter(
(mod) =>
!muteLists.admin.authors.includes(mod.author) &&
!muteLists.admin.replaceableEvents.includes(mod.aTag)
)
if (filterOptions.sort === SortBy.Latest) {
filtered.sort((a, b) => b.published_at - a.published_at)
} else if (filterOptions.sort === SortBy.Oldest) {
filtered.sort((a, b) => a.published_at - b.published_at)
}
showing > 0 && filtered.splice(showing)
return filtered
}, [
filterOptions.nsfw,
filterOptions.repost,
filterOptions.sort,
mods,
muteLists.admin.authors,
muteLists.admin.replaceableEvents,
nsfwList,
repostList,
showing
])
if (!userPubkey) return null
return (
<>
{isFetching && <LoadingSpinner desc='Fetching mod details from relays' />}
<div className='IBMSMSplitMainFullSideSec IBMSMSMFSSContent'>
<div className='IBMSMList IBMSMListFeed'>
{filteredModList.map((mod) => (
<ModCard key={mod.id} {...mod} />
))}
{filteredModList.length === 0 && !isFetching && (
<div className='IBMSMListFeedNoPosts'>
<p>You aren't following people (or there are no posts to show)</p>
</div>
)}
</div>
</div>
{!isFetching && isLoadMoreVisible && (
<div className='IBMSMListFeedLoadMore'>
<button
className='btn btnMain IBMSMListFeedLoadMoreBtn'
type='button'
onClick={handleLoadMore}
>
Load More
</button>
</div>
)}
</>
)
}

View File

@ -0,0 +1,3 @@
export const FeedTabPosts = () => {
return <>WIP: Posts</>
}

View File

@ -1,3 +1,22 @@
import { Tabs } from 'components/Tabs'
import { useState } from 'react'
import { FeedTabBlogs } from './FeedTabBlogs'
import { FeedTabMods } from './FeedTabMods'
import { FeedTabPosts } from './FeedTabPosts'
import { FeedFilter } from 'components/Filters/FeedFilter'
export const FeedPage = () => {
return <h2>Feed</h2>
const [tab, setTab] = useState(0)
return (
<>
<Tabs tabs={['Mods', 'Blogs', 'Posts']} tab={tab} setTab={setTab} />
<FeedFilter tab={tab} />
{tab === 0 && <FeedTabMods />}
{tab === 1 && <FeedTabBlogs />}
{tab === 2 && <FeedTabPosts />}
</>
)
}

101
src/pages/feed/loader.ts Normal file
View File

@ -0,0 +1,101 @@
import { NDKUser } from '@nostr-dev-kit/ndk'
import { NDKContextType } from 'contexts/NDKContext'
import { redirect } from 'react-router-dom'
import { appRoutes } from 'routes'
import { store } from 'store'
import { MuteLists } from 'types'
import {
CurationSetIdentifiers,
getFallbackPubkey,
getReportingSet,
log,
LogType
} from 'utils'
export interface FeedPageLoaderResult {
muteLists: {
admin: MuteLists
user: MuteLists
}
nsfwList: string[]
repostList: string[]
followList: string[]
}
export const feedPageLoader = (ndkContext: NDKContextType) => async () => {
// Empty result
const result: FeedPageLoaderResult = {
muteLists: {
admin: {
authors: [],
replaceableEvents: []
},
user: {
authors: [],
replaceableEvents: []
}
},
nsfwList: [],
repostList: [],
followList: []
}
// Get the current state
const userState = store.getState().user
const loggedInUserPubkey =
(userState?.user?.pubkey as string | undefined) || getFallbackPubkey()
if (!loggedInUserPubkey) return redirect(appRoutes.home)
const ndkUser = new NDKUser({ pubkey: loggedInUserPubkey })
ndkUser.ndk = ndkContext.ndk
const settled = await Promise.allSettled([
ndkContext.getMuteLists(loggedInUserPubkey),
getReportingSet(CurationSetIdentifiers.NSFW, ndkContext),
getReportingSet(CurationSetIdentifiers.Repost, ndkContext),
ndkUser.followSet()
])
// 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
)
}
// Check the followSet result
const followSetResult = settled[3]
if (followSetResult.status === 'fulfilled') {
result.followList = Array.from(followSetResult.value)
} else if (followSetResult.status === 'rejected') {
log(
true,
LogType.Error,
'Failed to fetch follow set.',
followSetResult.reason
)
}
return result
}

View File

@ -19,6 +19,7 @@ import { NotFoundPage } from '../pages/404'
import { submitModRouteAction } from 'pages/submitMod/action'
import { FeedLayout } from '../layout/feed'
import { FeedPage } from '../pages/feed'
import { feedPageLoader } from 'pages/feed/loader'
import { NotificationsPage } from '../pages/notifications'
import { WritePage } from '../pages/write'
import { writeRouteAction } from '../pages/write/action'
@ -197,7 +198,8 @@ export const routerWithNdkContext = (context: NDKContextType) =>
children: [
{
path: appRoutes.feed,
element: <FeedPage />
element: <FeedPage />,
loader: feedPageLoader(context)
},
{
path: appRoutes.notifications,

View File

@ -119,3 +119,27 @@
padding-top: 15px;
}
.IBMSMListFeedLoadMore {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
}
.btnMain.IBMSMListFeedLoadMoreBtn {
width: 100%;
max-width: 300px;
}
.IBMSMListFeedNoPosts {
width: 100%;
padding: 10px;
border-radius: 10px;
border: solid 1px rgba(255,255,255,0.1);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: rgba(255,255,255,0.25);
font-weight: 500;
}