From 586ed85a404aae383033c8581dff662ead49f2b3 Mon Sep 17 00:00:00 2001 From: artlu99 Date: Tue, 25 Jun 2024 10:41:57 -0400 Subject: [PATCH] For You front end --- src/api/followingFeed.api.ts | 2 +- src/api/forYouFeed.api.ts | 60 +++++- src/components/apps/forYouFeed/ForYouFeed.tsx | 187 ++++++++++++++++++ .../signalToNoiseOverlay/FeedSettings.tsx | 3 +- .../header/layouts/DesktopHeader.tsx | 15 +- .../header/layouts/MobileHeader.tsx | 5 +- .../main/sider/SiderMenu/SiderMenu.tsx | 4 +- .../layouts/main/sider/sidebarNavigation.tsx | 7 + src/components/router/AppRouter.tsx | 3 + src/constants/neynarPagination.ts | 1 + src/locales/de/translation.json | 1 + src/locales/en/translation.json | 1 + src/locales/es/translation.json | 1 + src/locales/ja/translation.json | 1 + src/pages/ForYouFeedPage.tsx | 21 ++ 15 files changed, 297 insertions(+), 15 deletions(-) create mode 100644 src/components/apps/forYouFeed/ForYouFeed.tsx create mode 100644 src/constants/neynarPagination.ts create mode 100644 src/pages/ForYouFeedPage.tsx diff --git a/src/api/followingFeed.api.ts b/src/api/followingFeed.api.ts index 79e15c5..cb2d646 100644 --- a/src/api/followingFeed.api.ts +++ b/src/api/followingFeed.api.ts @@ -10,7 +10,7 @@ import { FOLLOWING_FEED_PAGESIZE } from '@app/constants/pinataPagination'; import { listify, sift, unique } from 'radash'; import './mocks/mockornot'; -const getTagsForCast = (allChannels: ChannelObject[], parent_url?: string): IHashTag[] => { +export const getTagsForCast = (allChannels: ChannelObject[], parent_url?: string): IHashTag[] => { const maybeChannelObj = allChannels?.find((channel) => channel.url === parent_url); const tags: IHashTag[] = maybeChannelObj ? [{ title: maybeChannelObj.name, id: maybeChannelObj.id, bgColor: 'info' }] diff --git a/src/api/forYouFeed.api.ts b/src/api/forYouFeed.api.ts index 5a647e8..ed7b7e6 100644 --- a/src/api/forYouFeed.api.ts +++ b/src/api/forYouFeed.api.ts @@ -1,4 +1,12 @@ +import { getBotOrNot } from '@app/api/botOrNot.api'; +import { PagedCronFeed } from '@app/api/channelFeed.api'; +import { getCuration } from '@app/api/curation.api'; +import { FeedObject } from '@app/api/feed-types'; +import { getTagsForCast } from '@app/api/followingFeed.api'; import { httpApi } from '@app/api/http.api'; +import { ChannelObject } from '@app/api/warpcast-types'; +import { FORYOU_FEED_PAGESIZE } from '@app/constants/neynarPagination'; +import { sift, unique } from 'radash'; import './mocks/mockornot'; interface ForYouFeedRequest { @@ -7,11 +15,51 @@ interface ForYouFeedRequest { cursor?: string; } -interface ForYouFeedResponse { - casts: { - object: 'cast'; - }[]; +export const getNeynarOpenrankForYouFeed = (forYouFeedRequestPayload: ForYouFeedRequest): Promise => + httpApi.post('getForYouFeed', { ...forYouFeedRequestPayload }).then(({ data }) => data); + +interface EnhancedForYouFeedRequest { + fid: number; cursor?: string; + powerBadgeUsers: number[]; + allChannels: ChannelObject[]; } -export const getNeynarOpenrankForYouFeed = (forYouFeedRequestPayload: ForYouFeedRequest): Promise => - httpApi.post('getForYouFeed', { ...forYouFeedRequestPayload }).then(({ data }) => data); +export const getEnhancedForYouFeed = async ( + homeFeedRequestPayload: EnhancedForYouFeedRequest, +): Promise => { + const { fid, cursor, powerBadgeUsers, allChannels } = homeFeedRequestPayload; + + const forYouFeed = await getNeynarOpenrankForYouFeed({ fid: fid, limit: FORYOU_FEED_PAGESIZE, cursor }); + const seenFids = sift(forYouFeed.casts.map((cast) => cast.author.fid).filter((fid) => fid !== null)); + const seenHashes = unique(forYouFeed.casts.map((cast) => cast.hash)); + const botOrNotResponse = await getBotOrNot({ fids: seenFids ?? [] }); + const curation = await getCuration({ hashList: seenHashes ?? [] }); + + return { + ...forYouFeed, + casts: forYouFeed.casts.map((castObject) => ({ + ...castObject, + amFollowing: true, + + authorHasPowerBadge: powerBadgeUsers.find((fid) => fid === castObject.author.fid) !== undefined, + botOrNotResult: botOrNotResponse.fids.find((fid) => fid.fid === castObject.author.fid)?.result ?? { + label: '', + summary: '', + farcaptcha: false, + }, + tags: getTagsForCast(allChannels, castObject.parent_url), + curation: { + upvotes: curation.results.filter( + (result) => + result.votedFid === castObject.author.fid && result.hash === castObject.hash && result.action === 'upvote', + ), + downvotes: curation.results.filter( + (result) => + result.votedFid === castObject.author.fid && + result.hash === castObject.hash && + result.action === 'downvote', + ), + }, + })), + }; +}; diff --git a/src/components/apps/forYouFeed/ForYouFeed.tsx b/src/components/apps/forYouFeed/ForYouFeed.tsx new file mode 100644 index 0000000..5a7ac4f --- /dev/null +++ b/src/components/apps/forYouFeed/ForYouFeed.tsx @@ -0,0 +1,187 @@ +import { EnhancedCastObject } from '@app/api/channelFeed.api'; +import { getEnhancedForYouFeed } from '@app/api/forYouFeed.api'; +import { Cast } from '@app/components/apps/cast/Cast'; +import { AdvertFeed } from '@app/components/apps/channelFeed/AdvertFeed/AdvertFeed'; +import { BaseEmpty } from '@app/components/common/BaseEmpty/BaseEmpty'; +import { BaseFeed } from '@app/components/common/BaseFeed/BaseFeed'; +import { useAppSelector } from '@app/hooks/reduxHooks'; +import { allChannelsQuery, allPowerBadgeUsersQuery, followingByFidQuery } from '@app/queries/queries'; +import { useZustand } from '@app/store/zustand'; +import { useQuery } from '@tanstack/react-query'; +import { useEffect, useMemo, useState } from 'react'; + +interface ForYouFeedProps { + fid: number; +} +export const ForYouFeed: React.FC = ({ fid }) => { + const [allPowerBadgeUsers, setAllPowerBadgeUsers] = useState([]); + const [casts, setCasts] = useState([]); + const [nextCursor, setNextCursor] = useState(); + const [hasMore, setHasMore] = useState(true); + const [loaded, setLoaded] = useState(false); + const { + setNumCasts, + setNumCuratedChannelsCasts, + setNumFarcaptchas, + setNumUpvotes, + setNumDownvotes, + setNumCastsWithUpvotes, + setNumCastsWithDownvotes, + setNumCastsAboveThreshold, + setNumCastsAfterFiltering, + selectedLabels, + } = useZustand(); + + const signalToNoiseState = useAppSelector((state) => state.signalToNoise); + const showOnlyCuratedChannels = signalToNoiseState.showOnlyCuratedChannels; + const showOnlyFarcaptcha = signalToNoiseState.showOnlyFarcaptcha; + const onlyShowUpvoted = signalToNoiseState.onlyShowUpvoted; + const hideDownvoted = signalToNoiseState.hideDownvoted; + const onlyShowRatioAboveThreshold = signalToNoiseState.onlyShowRatioAboveThreshold; + const ratioThreshold = signalToNoiseState.ratioThreshold; + + const chQuery = useQuery(allChannelsQuery()); + const memodChannelData = useMemo(() => { + if (chQuery.isLoading || chQuery.error) return null; + return chQuery?.data?.result?.channels ?? []; + }, [chQuery.isLoading, chQuery.error, chQuery.data]); + + const ffQuery = useQuery(followingByFidQuery(fid)); + const memodFfData = useMemo(() => { + if (ffQuery.isLoading || ffQuery.error) return null; + return (ffQuery.data?.result?.users ?? []).map((u) => Number(u.fid)); + }, [ffQuery.isLoading, ffQuery.error, ffQuery.data]); + + const pbQuery = useQuery(allPowerBadgeUsersQuery()); + const memodPbData = useMemo(() => { + if (pbQuery.isLoading || pbQuery.error) return null; + return pbQuery.data; + }, [pbQuery.isLoading, pbQuery.error, pbQuery.data]); + + useEffect(() => { + const allPowerBadgeUsers = memodPbData?.result.fids ?? []; + setAllPowerBadgeUsers(allPowerBadgeUsers); + }, [memodPbData]); + + useEffect(() => { + setCasts([]); + + getEnhancedForYouFeed({ + fid: fid, + powerBadgeUsers: allPowerBadgeUsers, + allChannels: memodChannelData ?? [], + }) + .then((res) => { + setCasts(res.casts); + setNextCursor(res.next?.cursor); + setHasMore(!!res.next?.cursor); + }) + .finally(() => { + setLoaded(true); + }); + }, [fid, allPowerBadgeUsers, memodChannelData, memodFfData]); + + useEffect(() => { + setNumCasts(casts.length); + setNumCuratedChannelsCasts(casts.filter((c) => c.tags.length > 1).length); + setNumFarcaptchas(casts.filter((c) => c.botOrNotResult.farcaptcha).length); + setNumUpvotes(casts.reduce((acc, c) => acc + c.curation.upvotes.length, 0)); + setNumDownvotes(casts.reduce((acc, c) => acc + c.curation.downvotes.length, 0)); + setNumCastsWithUpvotes(casts.filter((c) => c.curation.upvotes.length > 0).length); + setNumCastsWithDownvotes(casts.filter((c) => c.curation.downvotes.length > 0).length); + setNumCastsAboveThreshold( + casts.filter((c) => c.curation.upvotes.length / (c.curation.downvotes.length + 0.00001) > ratioThreshold).length, + ); + }, [ + casts, + ratioThreshold, + setNumCasts, + setNumCuratedChannelsCasts, + setNumCastsAboveThreshold, + setNumCastsWithDownvotes, + setNumCastsWithUpvotes, + setNumDownvotes, + setNumFarcaptchas, + setNumUpvotes, + ]); + + const next = () => + getEnhancedForYouFeed({ + fid: fid, + cursor: nextCursor, + powerBadgeUsers: allPowerBadgeUsers, + allChannels: memodChannelData ?? [], + }).then((newCasts) => { + setNextCursor(newCasts.next?.cursor); + setCasts(casts.concat(newCasts.casts)); + }); + + const filteredCastsList = useMemo(() => { + const filteredCasts = casts + .filter((c) => !showOnlyCuratedChannels || c.tags.length > 1) + .filter((c) => !showOnlyFarcaptcha || c.botOrNotResult.farcaptcha) + .filter((c) => !onlyShowUpvoted || c.curation.upvotes.length > 0) + .filter((c) => (hideDownvoted ? c.curation.downvotes.length < 1 : true)) + .filter((c) => + onlyShowRatioAboveThreshold + ? c.curation.upvotes.length / (c.curation.downvotes.length + 0.00001) > ratioThreshold + : true, + ) + .filter((c) => + selectedLabels.length === 0 ? true : selectedLabels.includes(c.botOrNotResult.label ?? 'missing-label'), + ) + .map((post, index) => ( + '} + avatar={post.author.pfp_url} + parentHash={post.parent_hash} + threadHash={post.thread_hash} + parentUrl={post.parent_url} + replies={post.replies.count} + recasts={post.reactions.recasts_count} + recastooors={post.reactions.recasts.map((r) => r.fid)} + likes={post.reactions.likes_count} + likooors={post.reactions.likes.map((l) => l.fid)} + tags={post.tags} + curation={post.curation} + hasPowerBadge={post.authorHasPowerBadge} + botOrNotResult={post.botOrNotResult} + /> + )); + setNumCastsAfterFiltering(filteredCasts.length); + return filteredCasts; + }, [ + casts, + setNumCastsAfterFiltering, + showOnlyCuratedChannels, + showOnlyFarcaptcha, + onlyShowUpvoted, + hideDownvoted, + onlyShowRatioAboveThreshold, + ratioThreshold, + selectedLabels, + ]); + + return ( + + {({ castList }) => + castList?.length || !loaded ? ( + + {filteredCastsList} + + ) : ( + + ) + } + + ); +}; diff --git a/src/components/header/components/signalToNoiseDropdown/signalToNoiseOverlay/FeedSettings.tsx b/src/components/header/components/signalToNoiseDropdown/signalToNoiseOverlay/FeedSettings.tsx index 48a6901..49155e8 100644 --- a/src/components/header/components/signalToNoiseDropdown/signalToNoiseOverlay/FeedSettings.tsx +++ b/src/components/header/components/signalToNoiseDropdown/signalToNoiseOverlay/FeedSettings.tsx @@ -27,6 +27,7 @@ import styled from 'styled-components'; export const FeedSettings: React.FC = () => { const { pathname } = useLocation(); const isHomeFeed = pathname.startsWith('/home'); + const isForYouFeed = pathname.startsWith('/foryou'); const { t } = useTranslation(); const { @@ -83,7 +84,7 @@ export const FeedSettings: React.FC = () => {
{numCastsAfterFiltering} Casts after filtering - {isHomeFeed ? ( + {isHomeFeed || isForYouFeed ? ( <> {t('Only Curated Channels')} {numCuratedChannelsCasts} diff --git a/src/components/header/layouts/DesktopHeader.tsx b/src/components/header/layouts/DesktopHeader.tsx index 389059d..c4a1a38 100644 --- a/src/components/header/layouts/DesktopHeader.tsx +++ b/src/components/header/layouts/DesktopHeader.tsx @@ -15,6 +15,7 @@ interface DesktopHeaderProps { export const DesktopHeader: React.FC = ({ isTwoColumnsLayout }) => { const { pathname } = useLocation(); const isHomeFeed = pathname.startsWith('/home'); + const isForYouFeed = pathname.startsWith('/foryou'); const isChannelFeed = pathname.startsWith('/~/channel/'); const isDecentBookmarksPage = pathname.startsWith('/external/decent-bookmarks'); const isVotePage = pathname.startsWith('/votes'); @@ -22,7 +23,7 @@ export const DesktopHeader: React.FC = ({ isTwoColumnsLayout const leftSide = isTwoColumnsLayout ? ( - {(isHomeFeed || isChannelFeed) && ( + {(isHomeFeed || isForYouFeed || isChannelFeed) && ( @@ -39,7 +40,7 @@ export const DesktopHeader: React.FC = ({ isTwoColumnsLayout ) : ( <> - {(isHomeFeed || isChannelFeed) && ( + {(isHomeFeed || isForYouFeed || isChannelFeed) && ( <> @@ -55,7 +56,15 @@ export const DesktopHeader: React.FC = ({ isTwoColumnsLayout )} - {isChannelFeed ? : isHomeFeed ? : isVotePage ? : } + {isChannelFeed ? ( + + ) : isHomeFeed || isForYouFeed ? ( + + ) : isVotePage ? ( + + ) : ( + + )} ); diff --git a/src/components/header/layouts/MobileHeader.tsx b/src/components/header/layouts/MobileHeader.tsx index f4e20e4..6396d05 100644 --- a/src/components/header/layouts/MobileHeader.tsx +++ b/src/components/header/layouts/MobileHeader.tsx @@ -18,6 +18,7 @@ export const MobileHeader: React.FC = ({ toggleSider, isSider const { pathname } = useLocation(); const isHomeFeed = pathname.startsWith('/home'); + const isForYouFeed = pathname.startsWith('/foryou'); const isChannelFeed = pathname.startsWith('/~/channel/'); const isVotePage = pathname.startsWith('/votes'); @@ -30,7 +31,7 @@ export const MobileHeader: React.FC = ({ toggleSider, isSider - {(isHomeFeed || isChannelFeed) && ( + {(isHomeFeed || isForYouFeed || isChannelFeed) && ( <> @@ -44,7 +45,7 @@ export const MobileHeader: React.FC = ({ toggleSider, isSider {isChannelFeed ? ( - ) : isHomeFeed ? ( + ) : isHomeFeed || isForYouFeed ? ( ) : isVotePage ? ( diff --git a/src/components/layouts/main/sider/SiderMenu/SiderMenu.tsx b/src/components/layouts/main/sider/SiderMenu/SiderMenu.tsx index d38267e..2c084ef 100644 --- a/src/components/layouts/main/sider/SiderMenu/SiderMenu.tsx +++ b/src/components/layouts/main/sider/SiderMenu/SiderMenu.tsx @@ -61,7 +61,7 @@ const SiderMenu: React.FC = ({ setCollapsed }) => { ); const sidebarNavigation = [ - sidebarNavigationPre[0], + ...sidebarNavigationPre.slice(0, 2), { ...channelsSkeleton, children: [ @@ -82,7 +82,7 @@ const SiderMenu: React.FC = ({ setCollapsed }) => { ), ], }, - ...sidebarNavigationPre.slice(1), + ...sidebarNavigationPre.slice(2), ]; const sidebarNavFlat = sidebarNavigation.reduce( diff --git a/src/components/layouts/main/sider/sidebarNavigation.tsx b/src/components/layouts/main/sider/sidebarNavigation.tsx index b320520..cdd6cce 100644 --- a/src/components/layouts/main/sider/sidebarNavigation.tsx +++ b/src/components/layouts/main/sider/sidebarNavigation.tsx @@ -4,6 +4,7 @@ import { HomeOutlined, LayoutOutlined, LineChartOutlined, + UserOutlined, } from '@ant-design/icons'; import { ThumbsUpIcon } from 'lucide-react'; @@ -28,6 +29,12 @@ export const sidebarNavigation: SidebarNavigationItem[] = [ icon: , url: '/home', }, + { + title: 'sidebar.foryou', + key: 'foryou', + icon: , + url: '/foryou', + }, { title: 'sidebar.curated', key: 'curated', diff --git a/src/components/router/AppRouter.tsx b/src/components/router/AppRouter.tsx index f65ae76..fa45ff3 100644 --- a/src/components/router/AppRouter.tsx +++ b/src/components/router/AppRouter.tsx @@ -8,6 +8,7 @@ const BookmarksPage = React.lazy(() => import('@app/pages/BookmarksPage')); const LandingPage = React.lazy(() => import('@app/pages/LandingPage')); const ChannelFeedPage = React.lazy(() => import('@app/pages/ChannelFeedPage')); const FollowingFeedPage = React.lazy(() => import('@app/pages/FollowingFeedPage')); +const ForYouFeedPage = React.lazy(() => import('@app/pages/ForYouFeedPage')); const CuratedChannelsPage = React.lazy(() => import('@app/pages/CuratedChannelsPage')); const SponsorshipPage = React.lazy(() => import('@app/pages/SponsorshipPage')); const VotesPage = React.lazy(() => import('@app/pages/VotesPage')); @@ -16,6 +17,7 @@ const Error404Page = React.lazy(() => import('@app/pages/Error404Page')); const Landing = withLoading(LandingPage); const ChannelFeed = withLoading(ChannelFeedPage); const FollowingFeed = withLoading(FollowingFeedPage); +const ForYouFeed = withLoading(ForYouFeedPage); const Bookmarks = withLoading(BookmarksPage); const Sponsorship = withLoading(SponsorshipPage); const CuratedChannels = withLoading(CuratedChannelsPage); @@ -32,6 +34,7 @@ export const AppRouter: React.FC = () => { } /> } /> + } /> } /> diff --git a/src/constants/neynarPagination.ts b/src/constants/neynarPagination.ts new file mode 100644 index 0000000..6b88e3b --- /dev/null +++ b/src/constants/neynarPagination.ts @@ -0,0 +1 @@ +export const FORYOU_FEED_PAGESIZE = 50; diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index c832f65..1050e9d 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -132,6 +132,7 @@ "channels": "Kanälen", "curated": "Kuratierte Kanäle", "feed": "Futter", + "foryou": "Für Sie", "sponsorship": "Anzeige", "votes": "Gemeinschaftsbewertungen" }, diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 39afc10..6348fe6 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -132,6 +132,7 @@ "channels": "Channels", "curated": "Curated Channels", "feed": "Feed", + "foryou": "For You", "sponsorship": "Sponsorship", "votes": "Community Curation" }, diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 5b7a9cd..287cdbb 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -132,6 +132,7 @@ "channels": "Canales", "curated": "Canales seleccionados", "feed": "Fuente de noticias sociales", + "foryou": "Para Ud.", "sponsorship": "Patrocinio", "votes": "Curación comunitaria" }, diff --git a/src/locales/ja/translation.json b/src/locales/ja/translation.json index a26b283..3d4249f 100644 --- a/src/locales/ja/translation.json +++ b/src/locales/ja/translation.json @@ -132,6 +132,7 @@ "channels": "チャンネル", "curated": "厳選チャンネル", "feed": "ソーシャルフィード", + "foryou": "あなたのために", "sponsorship": "スポンサーシップ", "votes": "投票" }, diff --git a/src/pages/ForYouFeedPage.tsx b/src/pages/ForYouFeedPage.tsx new file mode 100644 index 0000000..029c844 --- /dev/null +++ b/src/pages/ForYouFeedPage.tsx @@ -0,0 +1,21 @@ +import { getFidWithFallback } from '@app/auth/fids'; +import { ForYouFeed } from '@app/components/apps/forYouFeed/ForYouFeed'; +import { PageTitle } from '@app/components/common/PageTitle/PageTitle'; +import { useNeynarContext } from '@neynar/react'; +import { useTranslation } from 'react-i18next'; + +const ForYouFeedPage: React.FC = () => { + const { user } = useNeynarContext(); + const fid = getFidWithFallback(user); + + const { t } = useTranslation(); + + return ( + <> + {t('sidebar.foryou')} + + + ); +}; + +export default ForYouFeedPage;