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 cursor: response.data.cursor, 116 }; 117 };
··· 115 cursor: response.data.cursor, 116 }; 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 import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; 2 - import { getFeedByUri, getFeedGenerators, getFeed } from "../api/feed"; 3 4 export const useFeedQuery = (feed: string, limit = 114, cursor?: string) => 5 useQuery({ ··· 35 getNextPageParam: (lastPage) => lastPage.nextCursor, 36 initialPageParam: undefined as string | undefined, 37 });
··· 1 import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; 2 + import { 3 + getFeedByUri, 4 + getFeedGenerators, 5 + getFeed, 6 + getScrobbles, 7 + } from "../api/feed"; 8 9 export const useFeedQuery = (feed: string, limit = 114, cursor?: string) => 10 useQuery({ ··· 40 getNextPageParam: (lastPage) => lastPage.nextCursor, 41 initialPageParam: undefined as string | undefined, 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 import ContentLoader from "react-content-loader"; 11 import Handle from "../../../components/Handle"; 12 import SongCover from "../../../components/SongCover"; 13 - import { useFeedInfiniteQuery } from "../../../hooks/useFeed"; 14 import { useEffect, useRef } from "react"; 15 import { WS_URL } from "../../../consts"; 16 import { useQueryClient } from "@tanstack/react-query"; 17 import FeedGenerators from "./FeedGenerators"; 18 import { useAtomValue } from "jotai"; 19 import { feedGeneratorUriAtom } from "../../../atoms/feed"; 20 21 dayjs.extend(relativeTime); 22 ··· 39 const heartbeatInterval = useRef<number | null>(null); 40 const loadMoreRef = useRef<HTMLDivElement | null>(null); 41 const feedUri = useAtomValue(feedGeneratorUriAtom); 42 const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = 43 useFeedInfiniteQuery(feedUri, 30); 44 45 - const allSongs = data?.pages.flatMap((page) => page.feed) || []; 46 47 useEffect(() => { 48 const ws = new WebSocket(`${WS_URL.replace("http", "ws")}`); ··· 86 87 // Intersection Observer for infinite scroll 88 useEffect(() => { 89 - if (!loadMoreRef.current || !hasNextPage || isFetchingNextPage) return; 90 91 const observer = new IntersectionObserver( 92 (entries) => { 93 - if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { 94 - fetchNextPage(); 95 } 96 }, 97 { threshold: 0.1 }, ··· 100 observer.observe(loadMoreRef.current); 101 102 return () => observer.disconnect(); 103 - }, [fetchNextPage, hasNextPage, isFetchingNextPage]); 104 105 return ( 106 <Container> 107 <FeedGenerators /> 108 - {isLoading && ( 109 <ContentLoader 110 width="100%" 111 height={800} ··· 126 </ContentLoader> 127 )} 128 129 - {!isLoading && ( 130 <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> 189 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)" 199 > 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> 207 </div> 208 )} 209 </Container>
··· 10 import ContentLoader from "react-content-loader"; 11 import Handle from "../../../components/Handle"; 12 import SongCover from "../../../components/SongCover"; 13 + import { 14 + useFeedInfiniteQuery, 15 + useScrobbleInfiniteQuery, 16 + } from "../../../hooks/useFeed"; 17 import { useEffect, useRef } from "react"; 18 import { WS_URL } from "../../../consts"; 19 import { useQueryClient } from "@tanstack/react-query"; 20 import FeedGenerators from "./FeedGenerators"; 21 import { useAtomValue } from "jotai"; 22 import { feedGeneratorUriAtom } from "../../../atoms/feed"; 23 + import { followingFeedAtom } from "../../../atoms/followingFeed"; 24 25 dayjs.extend(relativeTime); 26 ··· 43 const heartbeatInterval = useRef<number | null>(null); 44 const loadMoreRef = useRef<HTMLDivElement | null>(null); 45 const feedUri = useAtomValue(feedGeneratorUriAtom); 46 + const followingFeed = useAtomValue(followingFeedAtom); 47 const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = 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); 56 57 + const allSongs = followingFeed 58 + ? scrobbleData?.pages.flatMap((page) => page.scrobbles) || [] 59 + : data?.pages.flatMap((page) => page.feed) || []; 60 61 useEffect(() => { 62 const ws = new WebSocket(`${WS_URL.replace("http", "ws")}`); ··· 100 101 // Intersection Observer for infinite scroll 102 useEffect(() => { 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; 116 117 const observer = new IntersectionObserver( 118 (entries) => { 119 + if ( 120 + entries[0].isIntersecting && 121 + currentHasNextPage && 122 + !currentIsFetchingNextPage 123 + ) { 124 + if (followingFeed) { 125 + scrobbleFetchNextPage(); 126 + } else { 127 + fetchNextPage(); 128 + } 129 } 130 }, 131 { threshold: 0.1 }, ··· 134 observer.observe(loadMoreRef.current); 135 136 return () => observer.disconnect(); 137 + }, [ 138 + followingFeed, 139 + fetchNextPage, 140 + hasNextPage, 141 + isFetchingNextPage, 142 + scrobbleFetchNextPage, 143 + scrobbleHasNextPage, 144 + scrobbleIsFetchingNextPage, 145 + ]); 146 147 return ( 148 <Container> 149 <FeedGenerators /> 150 + {isLoading && scrobbleLoading && ( 151 <ContentLoader 152 width="100%" 153 height={800} ··· 168 </ContentLoader> 169 )} 170 171 + {!isLoading && !scrobbleLoading && ( 172 <div className="pb-[100px] pt-[20px]"> 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> 241 242 + {/* Load more trigger */} 243 + <div 244 + ref={loadMoreRef} 245 + style={{ height: "20px", marginTop: "20px" }} 246 > 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 + )} 280 </div> 281 )} 282 </Container>
+8 -1
apps/web/src/pages/home/feed/FeedGenerators/FeedGenerators.tsx
··· 9 } from "../../../../atoms/feed"; 10 import { useFeedGeneratorsQuery } from "../../../../hooks/useFeed"; 11 import * as R from "ramda"; 12 13 function FeedGenerators() { 14 const jwt = localStorage.getItem("token"); 15 const { data: feedGenerators } = useFeedGeneratorsQuery(); 16 const [feedUris, setFeedUris] = useAtom(feedUrisAtom); 17 const [, setFeedUri] = useAtom(feedGeneratorUriAtom); 18 const [activeCategory, setActiveCategory] = useAtom(feedAtom); 19 const [showLeftChevron, setShowLeftChevron] = useState(false); 20 const [showRightChevron, setShowRightChevron] = useState(true); ··· 74 75 const handleCategoryClick = (category: string, index: number) => { 76 setActiveCategory(category); 77 - setFeedUri(feedUris[category]); 78 79 const container = scrollContainerRef.current; 80 if (container) {
··· 9 } from "../../../../atoms/feed"; 10 import { useFeedGeneratorsQuery } from "../../../../hooks/useFeed"; 11 import * as R from "ramda"; 12 + import { followingFeedAtom } from "../../../../atoms/followingFeed"; 13 14 function FeedGenerators() { 15 const jwt = localStorage.getItem("token"); 16 const { data: feedGenerators } = useFeedGeneratorsQuery(); 17 const [feedUris, setFeedUris] = useAtom(feedUrisAtom); 18 const [, setFeedUri] = useAtom(feedGeneratorUriAtom); 19 + const [, setFollowingFeed] = useAtom(followingFeedAtom); 20 const [activeCategory, setActiveCategory] = useAtom(feedAtom); 21 const [showLeftChevron, setShowLeftChevron] = useState(false); 22 const [showRightChevron, setShowRightChevron] = useState(true); ··· 76 77 const handleCategoryClick = (category: string, index: number) => { 78 setActiveCategory(category); 79 + if (category !== "following") { 80 + setFeedUri(feedUris[category]); 81 + setFollowingFeed(false); 82 + } else { 83 + setFollowingFeed(true); 84 + } 85 86 const container = scrollContainerRef.current; 87 if (container) {
+4 -1
apps/web/src/pages/home/feed/FeedGenerators/constants.ts
··· 1 export const categories = [ 2 "all", 3 "afrobeat", 4 "afrobeats", 5 "alternative metal", ··· 50 "visual kei", 51 "vocaloid", 52 "west coast hip hop", 53 - ];
··· 1 export const categories = [ 2 "all", 3 + "following", 4 "afrobeat", 5 "afrobeats", 6 "alternative metal", ··· 51 "visual kei", 52 "vocaloid", 53 "west coast hip hop", 54 + ].filter((category) => 55 + localStorage.getItem("did") ? true : category !== "following", 56 + );