diff --git a/src/components/Filters/FeedFilter.tsx b/src/components/Filters/FeedFilter.tsx new file mode 100644 index 0000000..bdd8cb3 --- /dev/null +++ b/src/components/Filters/FeedFilter.tsx @@ -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) => { + const filterKey = `filter-feed-${tab}` + const [filterOptions, setFilterOptions] = useLocalStorage( + filterKey, + DEFAULT_FILTER_OPTIONS + ) + + return ( + + {/* sort filter options */} + {/* show only if not posts tabs */} + {tab !== 2 && ( + + {Object.values(SortBy).map((item, index) => ( + + ))} + + )} + + {/* nsfw filter options */} + + + + + {/* source filter options */} + + + + + + {children} + + ) + } +) diff --git a/src/pages/feed/FeedTabBlogs.tsx b/src/pages/feed/FeedTabBlogs.tsx new file mode 100644 index 0000000..f151f19 --- /dev/null +++ b/src/pages/feed/FeedTabBlogs.tsx @@ -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(filterKey, { + ...DEFAULT_FILTER_OPTIONS + }) + + if (!userPubkey) return null + return <> +} diff --git a/src/pages/feed/FeedTabMods.tsx b/src/pages/feed/FeedTabMods.tsx new file mode 100644 index 0000000..221c962 --- /dev/null +++ b/src/pages/feed/FeedTabMods.tsx @@ -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(filterKey, { + ...DEFAULT_FILTER_OPTIONS + }) + const { ndk } = useNDKContext() + const [mods, setMods] = useState([]) + 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 && } +
+
+ {filteredModList.map((mod) => ( + + ))} + {filteredModList.length === 0 && !isFetching && ( +
+

You aren't following people (or there are no posts to show)

+
+ )} +
+
+ {!isFetching && isLoadMoreVisible && ( +
+ +
+ )} + + ) +} diff --git a/src/pages/feed/FeedTabPosts.tsx b/src/pages/feed/FeedTabPosts.tsx new file mode 100644 index 0000000..c2ecfdc --- /dev/null +++ b/src/pages/feed/FeedTabPosts.tsx @@ -0,0 +1,3 @@ +export const FeedTabPosts = () => { + return <>WIP: Posts +} diff --git a/src/pages/feed/index.tsx b/src/pages/feed/index.tsx index 88694d9..a2bef4c 100644 --- a/src/pages/feed/index.tsx +++ b/src/pages/feed/index.tsx @@ -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

Feed

+ const [tab, setTab] = useState(0) + + return ( + <> + + + + + {tab === 0 && } + {tab === 1 && } + {tab === 2 && } + + ) } diff --git a/src/pages/feed/loader.ts b/src/pages/feed/loader.ts new file mode 100644 index 0000000..6f76ce8 --- /dev/null +++ b/src/pages/feed/loader.ts @@ -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 +} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index f26bfc9..066c487 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -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: + element: , + loader: feedPageLoader(context) }, { path: appRoutes.notifications, diff --git a/src/styles/feed.css b/src/styles/feed.css index 4ec6549..681d937 100644 --- a/src/styles/feed.css +++ b/src/styles/feed.css @@ -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; +}