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/components/ProfileSection.tsx b/src/components/ProfileSection.tsx index 16e8b0d..1ef0b6e 100644 --- a/src/components/ProfileSection.tsx +++ b/src/components/ProfileSection.tsx @@ -381,6 +381,15 @@ const FollowButton = ({ pubkey }: FollowButtonProps) => { } }) + // Hide follow if own profile + if ( + userState.auth && + userState.user?.pubkey && + userState.user?.pubkey === pubkey + ) { + return null + } + const getUserPubKey = async (): Promise => { if (userState.auth && userState.user?.pubkey) { return userState.user.pubkey as string diff --git a/src/layout/feed.tsx b/src/layout/feed.tsx index 6f68a29..f85f2ce 100644 --- a/src/layout/feed.tsx +++ b/src/layout/feed.tsx @@ -1,10 +1,39 @@ -import { Outlet } from 'react-router-dom' +import { Profile } from 'components/ProfileSection' +import { useAppSelector } from 'hooks' +import { Navigate, Outlet } from 'react-router-dom' +import { appRoutes } from 'routes' export const FeedLayout = () => { + const userState = useAppSelector((state) => state.user) + if (!userState.user?.pubkey) return + return (
-

WIP

- +
+
+
+
+
+
+ {userState.auth && userState.user?.pubkey && ( +
+
+
+ +
+
+
+ )} +
+
+ +
+
+
+
+
+
+
) } diff --git a/src/layout/socialNav.tsx b/src/layout/socialNav.tsx index b218b6e..7aa4b47 100644 --- a/src/layout/socialNav.tsx +++ b/src/layout/socialNav.tsx @@ -43,14 +43,18 @@ export const SocialNav = () => { viewBox='0 -32 576 576' svgPath='M511.8 287.6L512.5 447.7C512.5 450.5 512.3 453.1 512 455.8V472C512 494.1 494.1 512 472 512H456C454.9 512 453.8 511.1 452.7 511.9C451.3 511.1 449.9 512 448.5 512H392C369.9 512 352 494.1 352 472V384C352 366.3 337.7 352 320 352H256C238.3 352 224 366.3 224 384V472C224 494.1 206.1 512 184 512H128.1C126.6 512 125.1 511.9 123.6 511.8C122.4 511.9 121.2 512 120 512H104C81.91 512 64 494.1 64 472V360C64 359.1 64.03 358.1 64.09 357.2V287.6H32.05C14.02 287.6 0 273.5 0 255.5C0 246.5 3.004 238.5 10.01 231.5L266.4 8.016C273.4 1.002 281.4 0 288.4 0C295.4 0 303.4 2.004 309.5 7.014L416 100.7V64C416 46.33 430.3 32 448 32H480C497.7 32 512 46.33 512 64V185L564.8 231.5C572.8 238.5 576.9 246.5 575.8 255.5C575.8 273.5 560.8 287.6 543.8 287.6L511.8 287.6z' /> - - + {!!userState.auth && ( + <> + + + + )} { - return

Feed

-} diff --git a/src/pages/feed/FeedTabBlogs.tsx b/src/pages/feed/FeedTabBlogs.tsx new file mode 100644 index 0000000..d7f762e --- /dev/null +++ b/src/pages/feed/FeedTabBlogs.tsx @@ -0,0 +1,193 @@ +import { + NDKFilter, + NDKKind, + NDKSubscriptionCacheUsage +} from '@nostr-dev-kit/ndk' +import { useAppSelector, useLocalStorage, useNDKContext } from 'hooks' +import { useEffect, useMemo, useState } from 'react' +import { BlogCardDetails, FilterOptions, NSFWFilter, SortBy } from 'types' +import { DEFAULT_FILTER_OPTIONS, extractBlogCardDetails } from 'utils' +import { FeedPageLoaderResult } from './loader' +import { useLoaderData } from 'react-router-dom' +import { LoadingSpinner } from 'components/LoadingSpinner' +import { BlogCard } from 'components/BlogCard' + +export const FeedTabBlogs = () => { + const SHOWING_STEP = 10 + const { muteLists, nsfwList, followList } = + useLoaderData() as FeedPageLoaderResult + 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 + }) + const { ndk } = useNDKContext() + const [blogs, setBlogs] = useState[]>([]) + const [isFetching, setIsFetching] = useState(false) + const [isLoadMoreVisible, setIsLoadMoreVisible] = useState(true) + const [showing, setShowing] = useState(SHOWING_STEP) + + const handleLoadMore = () => { + const LOAD_MORE_STEP = SHOWING_STEP * 2 + setShowing((prev) => prev + SHOWING_STEP) + const lastBlog = filteredBlogs[filteredBlogs.length - 1] + const filter: NDKFilter = { + authors: [...followList], + kinds: [NDKKind.Article], + limit: LOAD_MORE_STEP + } + + if (filterOptions.nsfw === NSFWFilter.Only_NSFW) { + filter['#L'] = ['content-warning'] + } + + if (filterOptions.source === window.location.host) { + filter['#r'] = [window.location.host] + } + + if (filterOptions.sort === SortBy.Latest) { + filter.until = lastBlog.published_at + } else if (filterOptions.sort === SortBy.Oldest) { + filter.since = lastBlog.published_at + } + + setIsFetching(true) + ndk + .fetchEvents(filter, { + closeOnEose: true, + cacheUsage: NDKSubscriptionCacheUsage.PARALLEL + }) + .then((ndkEventSet) => { + setBlogs((prevBlogs) => { + const newBlogs = Array.from(ndkEventSet) + const combinedBlogs = [ + ...prevBlogs, + ...newBlogs.map(extractBlogCardDetails) + ] + const uniqueBlogs = Array.from( + new Set(combinedBlogs.map((b) => b.id)) + ) + .map((id) => combinedBlogs.find((b) => b.id === id)) + .filter((b): b is BlogCardDetails => b !== undefined) + + if (newBlogs.length < LOAD_MORE_STEP) { + setIsLoadMoreVisible(false) + } + + return uniqueBlogs + }) + }) + .finally(() => { + setIsFetching(false) + }) + } + + useEffect(() => { + setIsFetching(true) + setIsLoadMoreVisible(true) + const filter: NDKFilter = { + authors: [...followList], + kinds: [NDKKind.Article], + limit: 50 + } + + if (filterOptions.nsfw === NSFWFilter.Only_NSFW) { + filter['#L'] = ['content-warning'] + } + + 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) + setBlogs(ndkEvents.map(extractBlogCardDetails)) + }) + .finally(() => { + setIsFetching(false) + }) + }, [filterOptions.nsfw, filterOptions.source, followList, ndk]) + + const filteredBlogs = useMemo(() => { + let _blogs = blogs || [] + + // Add nsfw tag to blogs included in nsfwList + if (filterOptions.nsfw !== NSFWFilter.Hide_NSFW) { + _blogs = _blogs.map((b) => { + return !b.nsfw && b.aTag && nsfwList.includes(b.aTag) + ? { ...b, nsfw: true } + : b + }) + } + // Filter nsfw (Hide_NSFW option) + _blogs = _blogs.filter( + (b) => !(b.nsfw && filterOptions.nsfw === NSFWFilter.Hide_NSFW) + ) + + _blogs = _blogs.filter( + (b) => + !muteLists.admin.authors.includes(b.author!) && + !muteLists.admin.replaceableEvents.includes(b.aTag!) + ) + + if (filterOptions.sort === SortBy.Latest) { + _blogs.sort((a, b) => + a.published_at && b.published_at ? b.published_at - a.published_at : 0 + ) + } else if (filterOptions.sort === SortBy.Oldest) { + _blogs.sort((a, b) => + a.published_at && b.published_at ? a.published_at - b.published_at : 0 + ) + } + + showing > 0 && _blogs.splice(showing) + return _blogs + }, [ + blogs, + filterOptions.nsfw, + filterOptions.sort, + muteLists.admin.authors, + muteLists.admin.replaceableEvents, + nsfwList, + showing + ]) + + if (!userPubkey) return null + return ( + <> + {isFetching && ( + + )} + {filteredBlogs.length === 0 && !isFetching && ( +
+

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

+
+ )} +
+
+ {filteredBlogs.map((blog) => ( + + ))} +
+
+ {!isFetching && isLoadMoreVisible && filteredBlogs.length > 0 && ( +
+ +
+ )} + + ) +} diff --git a/src/pages/feed/FeedTabMods.tsx b/src/pages/feed/FeedTabMods.tsx new file mode 100644 index 0000000..c4f6932 --- /dev/null +++ b/src/pages/feed/FeedTabMods.tsx @@ -0,0 +1,229 @@ +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 SHOWING_STEP = 10 + 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(SHOWING_STEP) + + const handleLoadMore = () => { + const LOAD_MORE_STEP = SHOWING_STEP * 2 + setShowing((prev) => prev + SHOWING_STEP) + const lastMod = filteredModList[filteredModList.length - 1] + const filter: NDKFilter = { + authors: [...followList], + kinds: [NDKKind.Classified], + limit: LOAD_MORE_STEP + } + + 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 < LOAD_MORE_STEP) { + setIsLoadMoreVisible(false) + } + + return uniqueMods + }) + }) + .finally(() => { + setIsFetching(false) + }) + } + + useEffect(() => { + setIsFetching(true) + setIsLoadMoreVisible(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.length === 0 && !isFetching && ( +
+

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

+
+ )} +
+
+ {filteredModList.map((mod) => ( + + ))} +
+
+ {!isFetching && isLoadMoreVisible && filteredModList.length > 0 && ( +
+ +
+ )} + + ) +} 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 new file mode 100644 index 0000000..a2bef4c --- /dev/null +++ b/src/pages/feed/index.tsx @@ -0,0 +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 = () => { + 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/pages/notifications.tsx b/src/pages/notifications.tsx index 6632a58..a711642 100644 --- a/src/pages/notifications.tsx +++ b/src/pages/notifications.tsx @@ -1,3 +1,3 @@ export const NotificationsPage = () => { - return

Notifications

+ return

WIP: Notifications

} 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; +}