From 4170dfb0b39743097366ca652424c7d3b3b9633a Mon Sep 17 00:00:00 2001 From: Tsiry Sandratraina Date: Sat, 3 Jan 2026 08:40:44 +0300 Subject: [PATCH] Add following feed (scrobbles) support Add API getScrobbles and hooks for paginated scrobbles Introduce followingFeed atom and wire it into Feed to switch between generator feed and following scrobbles and handle infinite scrolling, loading state, and empty state Add "following" category to FeedGenerators (hidden when not logged in) --- apps/web/src/api/feed.ts | 63 +++++ apps/web/src/atoms/followingFeed.ts | 3 + apps/web/src/hooks/useFeed.tsx | 40 ++- apps/web/src/pages/home/feed/Feed.tsx | 237 ++++++++++++------ .../feed/FeedGenerators/FeedGenerators.tsx | 9 +- .../home/feed/FeedGenerators/constants.ts | 5 +- 6 files changed, 272 insertions(+), 85 deletions(-) create mode 100644 apps/web/src/atoms/followingFeed.ts 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", +); -- 2.51.2