A decentralized music tracking and discovery platform built on AT Protocol 🎵

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)

+272 -85
+63
apps/web/src/api/feed.ts
··· 115 115 cursor: response.data.cursor, 116 116 }; 117 117 }; 118 + 119 + export const getScrobbles = async ( 120 + did: string, 121 + following: boolean = false, 122 + offset: number = 0, 123 + limit: number = 50, 124 + ) => { 125 + const response = await client.get<{ 126 + scrobbles: { 127 + title: string; 128 + artist: string; 129 + albumArtist: string; 130 + album: string; 131 + trackNumber: number; 132 + duration: number; 133 + mbId: string | null; 134 + youtubeLink: string | null; 135 + spotifyLink: string | null; 136 + appleMusicLink: string | null; 137 + tidalLink: string | null; 138 + sha256: string; 139 + discNumber: number; 140 + composer: string | null; 141 + genre: string | null; 142 + label: string | null; 143 + copyrightMessage: string | null; 144 + uri: string; 145 + albumUri: string; 146 + artistUri: string; 147 + trackUri: string; 148 + xataVersion: number; 149 + cover: string; 150 + date: string; 151 + user: string; 152 + userDisplayName: string; 153 + userAvatar: string; 154 + tags: string[]; 155 + likesCount: number; 156 + liked: boolean; 157 + id: string; 158 + }[]; 159 + }>("/xrpc/app.rocksky.scrobble.getScrobbles", { 160 + params: { 161 + did, 162 + following, 163 + offset, 164 + limit, 165 + }, 166 + headers: { 167 + Authorization: localStorage.getItem("token") 168 + ? `Bearer ${localStorage.getItem("token")}` 169 + : undefined, 170 + }, 171 + }); 172 + 173 + if (response.status !== 200) { 174 + return { scrobbles: [] }; 175 + } 176 + 177 + return { 178 + scrobbles: response.data.scrobbles, 179 + }; 180 + };
+3
apps/web/src/atoms/followingFeed.ts
··· 1 + import { atom } from "jotai"; 2 + 3 + export const followingFeedAtom = atom<boolean>(false);
+39 -1
apps/web/src/hooks/useFeed.tsx
··· 1 1 import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; 2 - import { getFeedByUri, getFeedGenerators, getFeed } from "../api/feed"; 2 + import { 3 + getFeedByUri, 4 + getFeedGenerators, 5 + getFeed, 6 + getScrobbles, 7 + } from "../api/feed"; 3 8 4 9 export const useFeedQuery = (feed: string, limit = 114, cursor?: string) => 5 10 useQuery({ ··· 35 40 getNextPageParam: (lastPage) => lastPage.nextCursor, 36 41 initialPageParam: undefined as string | undefined, 37 42 }); 43 + 44 + export const useScrobblesQuery = ( 45 + did: string, 46 + following = false, 47 + offset = 0, 48 + limit = 50, 49 + ) => 50 + useQuery({ 51 + queryKey: ["scrobbles", did, following, offset, limit], 52 + queryFn: async () => { 53 + const data = await getScrobbles(did, following, offset, limit); 54 + return data.scrobbles; 55 + }, 56 + }); 57 + 58 + export const useScrobbleInfiniteQuery = ( 59 + did: string, 60 + following = false, 61 + limit = 50, 62 + ) => 63 + useInfiniteQuery({ 64 + queryKey: ["infiniteScrobbles", did, following], 65 + queryFn: async ({ pageParam }) => { 66 + const data = await getScrobbles(did, following, pageParam, limit); 67 + return { 68 + scrobbles: data.scrobbles, 69 + nextOffset: 70 + data.scrobbles.length === limit ? pageParam + limit : undefined, 71 + }; 72 + }, 73 + getNextPageParam: (lastPage) => lastPage.nextOffset, 74 + initialPageParam: 0, 75 + });
+155 -82
apps/web/src/pages/home/feed/Feed.tsx
··· 10 10 import ContentLoader from "react-content-loader"; 11 11 import Handle from "../../../components/Handle"; 12 12 import SongCover from "../../../components/SongCover"; 13 - import { useFeedInfiniteQuery } from "../../../hooks/useFeed"; 13 + import { 14 + useFeedInfiniteQuery, 15 + useScrobbleInfiniteQuery, 16 + } from "../../../hooks/useFeed"; 14 17 import { useEffect, useRef } from "react"; 15 18 import { WS_URL } from "../../../consts"; 16 19 import { useQueryClient } from "@tanstack/react-query"; 17 20 import FeedGenerators from "./FeedGenerators"; 18 21 import { useAtomValue } from "jotai"; 19 22 import { feedGeneratorUriAtom } from "../../../atoms/feed"; 23 + import { followingFeedAtom } from "../../../atoms/followingFeed"; 20 24 21 25 dayjs.extend(relativeTime); 22 26 ··· 39 43 const heartbeatInterval = useRef<number | null>(null); 40 44 const loadMoreRef = useRef<HTMLDivElement | null>(null); 41 45 const feedUri = useAtomValue(feedGeneratorUriAtom); 46 + const followingFeed = useAtomValue(followingFeedAtom); 42 47 const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = 43 48 useFeedInfiniteQuery(feedUri, 30); 49 + const { 50 + data: scrobbleData, 51 + isLoading: scrobbleLoading, 52 + fetchNextPage: scrobbleFetchNextPage, 53 + hasNextPage: scrobbleHasNextPage, 54 + isFetchingNextPage: scrobbleIsFetchingNextPage, 55 + } = useScrobbleInfiniteQuery(localStorage.getItem("did")!, true, 30); 44 56 45 - const allSongs = data?.pages.flatMap((page) => page.feed) || []; 57 + const allSongs = followingFeed 58 + ? scrobbleData?.pages.flatMap((page) => page.scrobbles) || [] 59 + : data?.pages.flatMap((page) => page.feed) || []; 46 60 47 61 useEffect(() => { 48 62 const ws = new WebSocket(`${WS_URL.replace("http", "ws")}`); ··· 86 100 87 101 // Intersection Observer for infinite scroll 88 102 useEffect(() => { 89 - if (!loadMoreRef.current || !hasNextPage || isFetchingNextPage) return; 103 + const currentHasNextPage = followingFeed 104 + ? scrobbleHasNextPage 105 + : hasNextPage; 106 + const currentIsFetchingNextPage = followingFeed 107 + ? scrobbleIsFetchingNextPage 108 + : isFetchingNextPage; 109 + 110 + if ( 111 + !loadMoreRef.current || 112 + !currentHasNextPage || 113 + currentIsFetchingNextPage 114 + ) 115 + return; 90 116 91 117 const observer = new IntersectionObserver( 92 118 (entries) => { 93 - if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { 94 - fetchNextPage(); 119 + if ( 120 + entries[0].isIntersecting && 121 + currentHasNextPage && 122 + !currentIsFetchingNextPage 123 + ) { 124 + if (followingFeed) { 125 + scrobbleFetchNextPage(); 126 + } else { 127 + fetchNextPage(); 128 + } 95 129 } 96 130 }, 97 131 { threshold: 0.1 }, ··· 100 134 observer.observe(loadMoreRef.current); 101 135 102 136 return () => observer.disconnect(); 103 - }, [fetchNextPage, hasNextPage, isFetchingNextPage]); 137 + }, [ 138 + followingFeed, 139 + fetchNextPage, 140 + hasNextPage, 141 + isFetchingNextPage, 142 + scrobbleFetchNextPage, 143 + scrobbleHasNextPage, 144 + scrobbleIsFetchingNextPage, 145 + ]); 104 146 105 147 return ( 106 148 <Container> 107 149 <FeedGenerators /> 108 - {isLoading && ( 150 + {isLoading && scrobbleLoading && ( 109 151 <ContentLoader 110 152 width="100%" 111 153 height={800} ··· 126 168 </ContentLoader> 127 169 )} 128 170 129 - {!isLoading && ( 171 + {!isLoading && !scrobbleLoading && ( 130 172 <div className="pb-[100px] pt-[20px]"> 131 - <FlexGrid 132 - flexGridColumnCount={[1, 2, 3]} 133 - flexGridColumnGap="scale800" 134 - flexGridRowGap="scale1000" 135 - > 136 - { 137 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 138 - allSongs.map((song: any) => ( 139 - <FlexGridItem {...itemProps} key={song.id}> 140 - <Link 141 - to="/$did/scrobble/$rkey" 142 - params={{ 143 - did: song.uri?.split("at://")[1]?.split("/")[0] || "", 144 - rkey: song.uri?.split("/").pop() || "", 145 - }} 146 - className="no-underline text-[var(--color-text-primary)]" 147 - > 148 - <SongCover 149 - uri={song.trackUri} 150 - cover={song.cover} 151 - artist={song.artist} 152 - title={song.title} 153 - liked={song.liked} 154 - likesCount={song.likesCount} 155 - withLikeButton 156 - /> 157 - </Link> 158 - <div className="flex"> 159 - <div className="mr-[8px]"> 160 - <Avatar 161 - src={song.userAvatar} 162 - name={song.userDisplayName} 163 - size={"20px"} 164 - /> 165 - </div> 166 - <Handle 167 - link={`/profile/${song.user}`} 168 - did={song.user} 169 - />{" "} 170 - </div> 171 - <LabelSmall className="!text-[var(--color-text-primary)]"> 172 - recently played this song 173 - </LabelSmall> 174 - <StatefulTooltip 175 - content={dayjs(song.date).format( 176 - "MMMM D, YYYY [at] HH:mm A", 177 - )} 178 - returnFocus 179 - autoFocus 180 - > 181 - <LabelSmall className="!text-[var(--color-text-muted)]"> 182 - {dayjs(song.date).fromNow()} 183 - </LabelSmall> 184 - </StatefulTooltip> 185 - </FlexGridItem> 186 - )) 187 - } 188 - </FlexGrid> 173 + {followingFeed && allSongs.length === 0 ? ( 174 + <div className="flex flex-col items-center justify-center py-20 mt-[100px]"> 175 + <LabelSmall className="!text-[var(--color-text-muted)] text-center"> 176 + No scrobbles from people you follow yet. 177 + <br /> 178 + Start following users to see their music activity here. 179 + </LabelSmall> 180 + </div> 181 + ) : ( 182 + <> 183 + <FlexGrid 184 + flexGridColumnCount={[1, 2, 3]} 185 + flexGridColumnGap="scale800" 186 + flexGridRowGap="scale1000" 187 + > 188 + { 189 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 190 + allSongs.map((song: any) => ( 191 + <FlexGridItem {...itemProps} key={song.id}> 192 + <Link 193 + to="/$did/scrobble/$rkey" 194 + params={{ 195 + did: song.uri?.split("at://")[1]?.split("/")[0] || "", 196 + rkey: song.uri?.split("/").pop() || "", 197 + }} 198 + className="no-underline text-[var(--color-text-primary)]" 199 + > 200 + <SongCover 201 + uri={song.trackUri} 202 + cover={song.cover} 203 + artist={song.artist} 204 + title={song.title} 205 + liked={song.liked} 206 + likesCount={song.likesCount} 207 + withLikeButton 208 + /> 209 + </Link> 210 + <div className="flex"> 211 + <div className="mr-[8px]"> 212 + <Avatar 213 + src={song.userAvatar} 214 + name={song.userDisplayName} 215 + size={"20px"} 216 + /> 217 + </div> 218 + <Handle 219 + link={`/profile/${song.user}`} 220 + did={song.user} 221 + />{" "} 222 + </div> 223 + <LabelSmall className="!text-[var(--color-text-primary)]"> 224 + recently played this song 225 + </LabelSmall> 226 + <StatefulTooltip 227 + content={dayjs(song.date).format( 228 + "MMMM D, YYYY [at] HH:mm A", 229 + )} 230 + returnFocus 231 + autoFocus 232 + > 233 + <LabelSmall className="!text-[var(--color-text-muted)]"> 234 + {dayjs(song.date).fromNow()} 235 + </LabelSmall> 236 + </StatefulTooltip> 237 + </FlexGridItem> 238 + )) 239 + } 240 + </FlexGrid> 189 241 190 - {/* Load more trigger */} 191 - <div ref={loadMoreRef} style={{ height: "20px", marginTop: "20px" }}> 192 - {isFetchingNextPage && ( 193 - <ContentLoader 194 - width="100%" 195 - height={360} 196 - viewBox="0 0 1100 360" 197 - backgroundColor="var(--color-skeleton-background)" 198 - foregroundColor="var(--color-skeleton-foreground)" 242 + {/* Load more trigger */} 243 + <div 244 + ref={loadMoreRef} 245 + style={{ height: "20px", marginTop: "20px" }} 199 246 > 200 - {/* 3 items with 24px gap (scale800) */} 201 - <rect x="0" y="10" rx="2" ry="2" width="349" height="349" /> 202 - <rect x="373" y="10" rx="2" ry="2" width="349" height="349" /> 203 - <rect x="746" y="10" rx="2" ry="2" width="349" height="349" /> 204 - </ContentLoader> 205 - )} 206 - </div> 247 + {(followingFeed 248 + ? scrobbleIsFetchingNextPage 249 + : isFetchingNextPage) && ( 250 + <ContentLoader 251 + width="100%" 252 + height={360} 253 + viewBox="0 0 1100 360" 254 + backgroundColor="var(--color-skeleton-background)" 255 + foregroundColor="var(--color-skeleton-foreground)" 256 + > 257 + {/* 3 items with 24px gap (scale800) */} 258 + <rect x="0" y="10" rx="2" ry="2" width="349" height="349" /> 259 + <rect 260 + x="373" 261 + y="10" 262 + rx="2" 263 + ry="2" 264 + width="349" 265 + height="349" 266 + /> 267 + <rect 268 + x="746" 269 + y="10" 270 + rx="2" 271 + ry="2" 272 + width="349" 273 + height="349" 274 + /> 275 + </ContentLoader> 276 + )} 277 + </div> 278 + </> 279 + )} 207 280 </div> 208 281 )} 209 282 </Container>
+8 -1
apps/web/src/pages/home/feed/FeedGenerators/FeedGenerators.tsx
··· 9 9 } from "../../../../atoms/feed"; 10 10 import { useFeedGeneratorsQuery } from "../../../../hooks/useFeed"; 11 11 import * as R from "ramda"; 12 + import { followingFeedAtom } from "../../../../atoms/followingFeed"; 12 13 13 14 function FeedGenerators() { 14 15 const jwt = localStorage.getItem("token"); 15 16 const { data: feedGenerators } = useFeedGeneratorsQuery(); 16 17 const [feedUris, setFeedUris] = useAtom(feedUrisAtom); 17 18 const [, setFeedUri] = useAtom(feedGeneratorUriAtom); 19 + const [, setFollowingFeed] = useAtom(followingFeedAtom); 18 20 const [activeCategory, setActiveCategory] = useAtom(feedAtom); 19 21 const [showLeftChevron, setShowLeftChevron] = useState(false); 20 22 const [showRightChevron, setShowRightChevron] = useState(true); ··· 74 76 75 77 const handleCategoryClick = (category: string, index: number) => { 76 78 setActiveCategory(category); 77 - setFeedUri(feedUris[category]); 79 + if (category !== "following") { 80 + setFeedUri(feedUris[category]); 81 + setFollowingFeed(false); 82 + } else { 83 + setFollowingFeed(true); 84 + } 78 85 79 86 const container = scrollContainerRef.current; 80 87 if (container) {
+4 -1
apps/web/src/pages/home/feed/FeedGenerators/constants.ts
··· 1 1 export const categories = [ 2 2 "all", 3 + "following", 3 4 "afrobeat", 4 5 "afrobeats", 5 6 "alternative metal", ··· 50 51 "visual kei", 51 52 "vocaloid", 52 53 "west coast hip hop", 53 - ]; 54 + ].filter((category) => 55 + localStorage.getItem("did") ? true : category !== "following", 56 + );