diff --git a/apps/web/src/api/feed.ts b/apps/web/src/api/feed.ts index d7260dec..396e2c49 100644 --- a/apps/web/src/api/feed.ts +++ b/apps/web/src/api/feed.ts @@ -115,3 +115,66 @@ export const getFeed = async (uri: string, limit?: number, cursor?: string) => { cursor: response.data.cursor, }; }; + +export const getScrobbles = async ( + did: string, + following: boolean = false, + offset: number = 0, + limit: number = 50, +) => { + const response = await client.get<{ + scrobbles: { + title: string; + artist: string; + albumArtist: string; + album: string; + trackNumber: number; + duration: number; + mbId: string | null; + youtubeLink: string | null; + spotifyLink: string | null; + appleMusicLink: string | null; + tidalLink: string | null; + sha256: string; + discNumber: number; + composer: string | null; + genre: string | null; + label: string | null; + copyrightMessage: string | null; + uri: string; + albumUri: string; + artistUri: string; + trackUri: string; + xataVersion: number; + cover: string; + date: string; + user: string; + userDisplayName: string; + userAvatar: string; + tags: string[]; + likesCount: number; + liked: boolean; + id: string; + }[]; + }>("/xrpc/app.rocksky.scrobble.getScrobbles", { + params: { + did, + following, + offset, + limit, + }, + headers: { + Authorization: localStorage.getItem("token") + ? `Bearer ${localStorage.getItem("token")}` + : undefined, + }, + }); + + if (response.status !== 200) { + return { scrobbles: [] }; + } + + return { + scrobbles: response.data.scrobbles, + }; +}; diff --git a/apps/web/src/atoms/followingFeed.ts b/apps/web/src/atoms/followingFeed.ts new file mode 100644 index 00000000..5ea1c305 --- /dev/null +++ b/apps/web/src/atoms/followingFeed.ts @@ -0,0 +1,3 @@ +import { atom } from "jotai"; + +export const followingFeedAtom = atom(false); diff --git a/apps/web/src/hooks/useFeed.tsx b/apps/web/src/hooks/useFeed.tsx index 5d9b65e5..a4dad974 100644 --- a/apps/web/src/hooks/useFeed.tsx +++ b/apps/web/src/hooks/useFeed.tsx @@ -1,5 +1,10 @@ import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; -import { getFeedByUri, getFeedGenerators, getFeed } from "../api/feed"; +import { + getFeedByUri, + getFeedGenerators, + getFeed, + getScrobbles, +} from "../api/feed"; export const useFeedQuery = (feed: string, limit = 114, cursor?: string) => useQuery({ @@ -35,3 +40,36 @@ export const useFeedInfiniteQuery = (feed: string, limit = 30) => getNextPageParam: (lastPage) => lastPage.nextCursor, initialPageParam: undefined as string | undefined, }); + +export const useScrobblesQuery = ( + did: string, + following = false, + offset = 0, + limit = 50, +) => + useQuery({ + queryKey: ["scrobbles", did, following, offset, limit], + queryFn: async () => { + const data = await getScrobbles(did, following, offset, limit); + return data.scrobbles; + }, + }); + +export const useScrobbleInfiniteQuery = ( + did: string, + following = false, + limit = 50, +) => + useInfiniteQuery({ + queryKey: ["infiniteScrobbles", did, following], + queryFn: async ({ pageParam }) => { + const data = await getScrobbles(did, following, pageParam, limit); + return { + scrobbles: data.scrobbles, + nextOffset: + data.scrobbles.length === limit ? pageParam + limit : undefined, + }; + }, + getNextPageParam: (lastPage) => lastPage.nextOffset, + initialPageParam: 0, + }); diff --git a/apps/web/src/pages/home/feed/Feed.tsx b/apps/web/src/pages/home/feed/Feed.tsx index 7f622e85..38fb43ae 100644 --- a/apps/web/src/pages/home/feed/Feed.tsx +++ b/apps/web/src/pages/home/feed/Feed.tsx @@ -10,13 +10,17 @@ import relativeTime from "dayjs/plugin/relativeTime"; import ContentLoader from "react-content-loader"; import Handle from "../../../components/Handle"; import SongCover from "../../../components/SongCover"; -import { useFeedInfiniteQuery } from "../../../hooks/useFeed"; +import { + useFeedInfiniteQuery, + useScrobbleInfiniteQuery, +} from "../../../hooks/useFeed"; import { useEffect, useRef } from "react"; import { WS_URL } from "../../../consts"; import { useQueryClient } from "@tanstack/react-query"; import FeedGenerators from "./FeedGenerators"; import { useAtomValue } from "jotai"; import { feedGeneratorUriAtom } from "../../../atoms/feed"; +import { followingFeedAtom } from "../../../atoms/followingFeed"; dayjs.extend(relativeTime); @@ -39,10 +43,20 @@ function Feed() { const heartbeatInterval = useRef(null); const loadMoreRef = useRef(null); const feedUri = useAtomValue(feedGeneratorUriAtom); + const followingFeed = useAtomValue(followingFeedAtom); const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = useFeedInfiniteQuery(feedUri, 30); + const { + data: scrobbleData, + isLoading: scrobbleLoading, + fetchNextPage: scrobbleFetchNextPage, + hasNextPage: scrobbleHasNextPage, + isFetchingNextPage: scrobbleIsFetchingNextPage, + } = useScrobbleInfiniteQuery(localStorage.getItem("did")!, true, 30); - const allSongs = data?.pages.flatMap((page) => page.feed) || []; + const allSongs = followingFeed + ? scrobbleData?.pages.flatMap((page) => page.scrobbles) || [] + : data?.pages.flatMap((page) => page.feed) || []; useEffect(() => { const ws = new WebSocket(`${WS_URL.replace("http", "ws")}`); @@ -86,12 +100,32 @@ function Feed() { // Intersection Observer for infinite scroll useEffect(() => { - if (!loadMoreRef.current || !hasNextPage || isFetchingNextPage) return; + const currentHasNextPage = followingFeed + ? scrobbleHasNextPage + : hasNextPage; + const currentIsFetchingNextPage = followingFeed + ? scrobbleIsFetchingNextPage + : isFetchingNextPage; + + if ( + !loadMoreRef.current || + !currentHasNextPage || + currentIsFetchingNextPage + ) + return; const observer = new IntersectionObserver( (entries) => { - if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { - fetchNextPage(); + if ( + entries[0].isIntersecting && + currentHasNextPage && + !currentIsFetchingNextPage + ) { + if (followingFeed) { + scrobbleFetchNextPage(); + } else { + fetchNextPage(); + } } }, { threshold: 0.1 }, @@ -100,12 +134,20 @@ function Feed() { observer.observe(loadMoreRef.current); return () => observer.disconnect(); - }, [fetchNextPage, hasNextPage, isFetchingNextPage]); + }, [ + followingFeed, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + scrobbleFetchNextPage, + scrobbleHasNextPage, + scrobbleIsFetchingNextPage, + ]); return ( - {isLoading && ( + {isLoading && scrobbleLoading && ( )} - {!isLoading && ( + {!isLoading && !scrobbleLoading && (
- - { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - allSongs.map((song: any) => ( - - + + No scrobbles from people you follow yet. +
+ Start following users to see their music activity here. +
+
+ ) : ( + <> + + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + allSongs.map((song: any) => ( + + + + +
+
+ +
+ {" "} +
+ + recently played this song + + + + {dayjs(song.date).fromNow()} + + +
+ )) + } +
+ + {/* Load more trigger */} +
+ {(followingFeed + ? scrobbleIsFetchingNextPage + : isFetchingNextPage) && ( + - + - -
-
- -
- {" "} -
- - recently played this song - - - - {dayjs(song.date).fromNow()} - - - - )) - } - - - {/* Load more trigger */} -
- {isFetchingNextPage && ( - - {/* 3 items with 24px gap (scale800) */} - - - - - )} -
+ +
+ )} +
+ + )} )}
diff --git a/apps/web/src/pages/home/feed/FeedGenerators/FeedGenerators.tsx b/apps/web/src/pages/home/feed/FeedGenerators/FeedGenerators.tsx index 33dfd433..c756719b 100644 --- a/apps/web/src/pages/home/feed/FeedGenerators/FeedGenerators.tsx +++ b/apps/web/src/pages/home/feed/FeedGenerators/FeedGenerators.tsx @@ -9,12 +9,14 @@ import { } from "../../../../atoms/feed"; import { useFeedGeneratorsQuery } from "../../../../hooks/useFeed"; import * as R from "ramda"; +import { followingFeedAtom } from "../../../../atoms/followingFeed"; function FeedGenerators() { const jwt = localStorage.getItem("token"); const { data: feedGenerators } = useFeedGeneratorsQuery(); const [feedUris, setFeedUris] = useAtom(feedUrisAtom); const [, setFeedUri] = useAtom(feedGeneratorUriAtom); + const [, setFollowingFeed] = useAtom(followingFeedAtom); const [activeCategory, setActiveCategory] = useAtom(feedAtom); const [showLeftChevron, setShowLeftChevron] = useState(false); const [showRightChevron, setShowRightChevron] = useState(true); @@ -74,7 +76,12 @@ function FeedGenerators() { const handleCategoryClick = (category: string, index: number) => { setActiveCategory(category); - setFeedUri(feedUris[category]); + if (category !== "following") { + setFeedUri(feedUris[category]); + setFollowingFeed(false); + } else { + setFollowingFeed(true); + } const container = scrollContainerRef.current; if (container) { diff --git a/apps/web/src/pages/home/feed/FeedGenerators/constants.ts b/apps/web/src/pages/home/feed/FeedGenerators/constants.ts index 3d2a2733..3c108811 100644 --- a/apps/web/src/pages/home/feed/FeedGenerators/constants.ts +++ b/apps/web/src/pages/home/feed/FeedGenerators/constants.ts @@ -1,5 +1,6 @@ export const categories = [ "all", + "following", "afrobeat", "afrobeats", "alternative metal", @@ -50,4 +51,6 @@ export const categories = [ "visual kei", "vocaloid", "west coast hip hop", -]; +].filter((category) => + localStorage.getItem("did") ? true : category !== "following", +);