feat(feed): mods feed
This commit is contained in:
parent
2053e22753
commit
31d9a2b258
83
src/components/Filters/FeedFilter.tsx
Normal file
83
src/components/Filters/FeedFilter.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
)
|
16
src/pages/feed/FeedTabBlogs.tsx
Normal file
16
src/pages/feed/FeedTabBlogs.tsx
Normal 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 <></>
|
||||
}
|
226
src/pages/feed/FeedTabMods.tsx
Normal file
226
src/pages/feed/FeedTabMods.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
3
src/pages/feed/FeedTabPosts.tsx
Normal file
3
src/pages/feed/FeedTabPosts.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export const FeedTabPosts = () => {
|
||||
return <>WIP: Posts</>
|
||||
}
|
@ -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
101
src/pages/feed/loader.ts
Normal 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
|
||||
}
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user