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

Add cursor-based pagination to feed

+167 -71
+4
apps/api/lexicons/feed/defs.json
··· 167 167 "type": "ref", 168 168 "ref": "app.rocksky.feed.defs#feedItemView" 169 169 } 170 + }, 171 + "cursor": { 172 + "type": "string", 173 + "description": "The pagination cursor for the next set of results." 170 174 } 171 175 } 172 176 }
+4
apps/api/pkl/defs/feed/defs.pkl
··· 171 171 ref = "app.rocksky.feed.defs#feedItemView" 172 172 } 173 173 } 174 + ["cursor"] = new StringType { 175 + type = "string" 176 + description = "The pagination cursor for the next set of results." 177 + } 174 178 } 175 179 } 176 180 }
+4
apps/api/src/lexicon/lexicons.ts
··· 2343 2343 ref: "lex:app.rocksky.feed.defs#feedItemView", 2344 2344 }, 2345 2345 }, 2346 + cursor: { 2347 + type: "string", 2348 + description: "The pagination cursor for the next set of results.", 2349 + }, 2346 2350 }, 2347 2351 }, 2348 2352 },
+2
apps/api/src/lexicon/types/app/rocksky/feed/defs.ts
··· 164 164 165 165 export interface FeedView { 166 166 feed?: FeedItemView[]; 167 + /** The pagination cursor for the next set of results. */ 168 + cursor?: string; 167 169 [k: string]: unknown; 168 170 } 169 171
+35 -22
apps/api/src/xrpc/app/rocksky/feed/getFeed.ts
··· 62 62 ? "http://localhost:8002" 63 63 : `https://${feed.did.split("did:web:")[1]}`; 64 64 const response = await axios.get<{ 65 - cusrsor: string; 65 + cursor?: string; 66 66 feed: { scrobble: string }[]; 67 67 }>(`${feedUrl}/xrpc/app.rocksky.feed.getFeedSkeleton`, { 68 68 params: { ··· 73 73 }); 74 74 return { 75 75 uris: response.data.feed.map(({ scrobble }) => scrobble), 76 + cursor: response.data.cursor, 76 77 ctx, 77 78 did, 78 79 }; ··· 83 84 84 85 const hydrate = ({ 85 86 uris, 87 + cursor, 86 88 ctx, 87 89 did, 88 90 }: { 89 91 uris: string[]; 92 + cursor?: string; 90 93 ctx: Context; 91 94 did?: string; 92 - }): Effect.Effect<Scrobbles | undefined, Error> => { 95 + }): Effect.Effect<ScrobblesWithCursor | undefined, Error> => { 93 96 return Effect.tryPromise({ 94 97 try: async () => { 95 98 const scrobbles = await ctx.db ··· 128 131 liked: likesMap.get(row.tracks?.id)?.liked ?? false, 129 132 })); 130 133 131 - return result; 134 + return { scrobbles: result, cursor }; 132 135 }, 133 136 134 137 catch: (error) => new Error(`Failed to hydrate feed: ${error}`), 135 138 }); 136 139 }; 137 140 138 - const presentation = (data: Scrobbles): Effect.Effect<FeedView, never> => { 141 + const presentation = ( 142 + data: ScrobblesWithCursor, 143 + ): Effect.Effect<FeedView, never> => { 139 144 return Effect.sync(() => ({ 140 - feed: data.map(({ scrobbles, tracks, users, likesCount, liked }) => ({ 141 - scrobble: { 142 - ...R.omit(["albumArt", "id", "lyrics"])(tracks), 143 - cover: tracks.albumArt, 144 - date: scrobbles.timestamp.toISOString(), 145 - user: users.handle, 146 - userDisplayName: users.displayName, 147 - userAvatar: users.avatar, 148 - uri: scrobbles.uri, 149 - tags: [], 150 - likesCount, 151 - liked, 152 - trackUri: tracks.uri, 153 - createdAt: scrobbles.createdAt.toISOString(), 154 - updatedAt: scrobbles.updatedAt.toISOString(), 155 - id: scrobbles.id, 156 - }, 157 - })), 145 + feed: data.scrobbles.map( 146 + ({ scrobbles, tracks, users, likesCount, liked }) => ({ 147 + scrobble: { 148 + ...R.omit(["albumArt", "id", "lyrics"])(tracks), 149 + cover: tracks.albumArt, 150 + date: scrobbles.timestamp.toISOString(), 151 + user: users.handle, 152 + userDisplayName: users.displayName, 153 + userAvatar: users.avatar, 154 + uri: scrobbles.uri, 155 + tags: [], 156 + likesCount, 157 + liked, 158 + trackUri: tracks.uri, 159 + createdAt: scrobbles.createdAt.toISOString(), 160 + updatedAt: scrobbles.updatedAt.toISOString(), 161 + id: scrobbles.id, 162 + }, 163 + }), 164 + ), 165 + cursor: data.cursor, 158 166 })); 159 167 }; 160 168 ··· 165 173 likesCount: number; 166 174 liked: boolean; 167 175 }[]; 176 + 177 + type ScrobblesWithCursor = { 178 + scrobbles: Scrobbles; 179 + cursor?: string; 180 + };
+6 -2
apps/web/src/api/feed.ts
··· 92 92 id: string; 93 93 }; 94 94 }[]; 95 + cursor?: string; 95 96 }>("/xrpc/app.rocksky.feed.getFeed", { 96 97 params: { 97 98 feed: uri, ··· 106 107 }); 107 108 108 109 if (response.status !== 200) { 109 - return []; 110 + return { songs: [], cursor: undefined }; 110 111 } 111 112 112 - return response.data.feed.map(({ scrobble }) => scrobble); 113 + return { 114 + songs: response.data.feed.map(({ scrobble }) => scrobble), 115 + cursor: response.data.cursor, 116 + }; 113 117 };
+19 -2
apps/web/src/hooks/useFeed.tsx
··· 1 - import { useQuery } from "@tanstack/react-query"; 1 + import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; 2 2 import { getFeedByUri, getFeedGenerators, getFeed } from "../api/feed"; 3 3 4 4 export const useFeedQuery = (feed: string, limit = 114, cursor?: string) => 5 5 useQuery({ 6 6 queryKey: ["feed", feed], 7 - queryFn: () => getFeed(feed, limit, cursor), 7 + queryFn: async () => { 8 + const data = await getFeed(feed, limit, cursor); 9 + return data.songs; 10 + }, 8 11 }); 9 12 10 13 export const useFeedByUriQuery = (uri: string) => ··· 18 21 queryKey: ["feedGenerators"], 19 22 queryFn: () => getFeedGenerators(), 20 23 }); 24 + 25 + export const useFeedInfiniteQuery = (feed: string, limit = 30) => 26 + useInfiniteQuery({ 27 + queryKey: ["infiniteFeed", feed], 28 + queryFn: async ({ pageParam }) => { 29 + const data = await getFeed(feed, limit, pageParam); 30 + return { 31 + feed: data.songs, 32 + nextCursor: data.cursor, 33 + }; 34 + }, 35 + getNextPageParam: (lastPage) => lastPage.nextCursor, 36 + initialPageParam: undefined as string | undefined, 37 + });
+93 -45
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 { useFeedQuery } from "../../../hooks/useFeed"; 13 + import { useFeedInfiniteQuery } from "../../../hooks/useFeed"; 14 14 import { useEffect, useRef } from "react"; 15 15 import { WS_URL } from "../../../consts"; 16 16 import { useQueryClient } from "@tanstack/react-query"; ··· 37 37 const queryClient = useQueryClient(); 38 38 const socketRef = useRef<WebSocket | null>(null); 39 39 const heartbeatInterval = useRef<number | null>(null); 40 + const loadMoreRef = useRef<HTMLDivElement | null>(null); 40 41 const feedUri = useAtomValue(feedGeneratorUriAtom); 41 - const { data, isLoading } = useFeedQuery(feedUri); 42 + const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = 43 + useFeedInfiniteQuery(feedUri, 30); 44 + 45 + const allSongs = data?.pages.flatMap((page) => page.feed) || []; 42 46 43 47 useEffect(() => { 44 48 const ws = new WebSocket(`${WS_URL.replace("http", "ws")}`); ··· 62 66 () => message.scrobblesChart, 63 67 ); 64 68 65 - await queryClient.invalidateQueries({ queryKey: ["feed", feedUri] }); 69 + await queryClient.invalidateQueries({ 70 + queryKey: ["infiniteFeed", feedUri], 71 + }); 66 72 await queryClient.invalidateQueries({ queryKey: ["now-playings"] }); 67 73 await queryClient.invalidateQueries({ queryKey: ["scrobblesChart"] }); 68 74 }; ··· 78 84 }; 79 85 }, [queryClient, feedUri]); 80 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 }, 98 + ); 99 + 100 + observer.observe(loadMoreRef.current); 101 + 102 + return () => observer.disconnect(); 103 + }, [fetchNextPage, hasNextPage, isFetchingNextPage]); 104 + 81 105 return ( 82 106 <Container> 83 107 <FeedGenerators /> ··· 114 138 flexGridColumnGap="scale800" 115 139 flexGridRowGap="scale1000" 116 140 > 117 - {// eslint-disable-next-line @typescript-eslint/no-explicit-any 118 - data?.map((song: any) => ( 119 - <FlexGridItem {...itemProps} key={song.id}> 120 - <Link 121 - to="/$did/scrobble/$rkey" 122 - params={{ 123 - did: song.uri?.split("at://")[1]?.split("/")[0] || "", 124 - rkey: song.uri?.split("/").pop() || "", 125 - }} 126 - className="no-underline text-[var(--color-text-primary)]" 127 - > 128 - <SongCover 129 - uri={song.trackUri} 130 - cover={song.cover} 131 - artist={song.artist} 132 - title={song.title} 133 - liked={song.liked} 134 - likesCount={song.likesCount} 135 - withLikeButton 136 - /> 137 - </Link> 138 - <div className="flex"> 139 - <div className="mr-[8px]"> 140 - <Avatar 141 - src={song.userAvatar} 142 - name={song.userDisplayName} 143 - size={"20px"} 141 + { 142 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 143 + allSongs.map((song: any) => ( 144 + <FlexGridItem {...itemProps} key={song.id}> 145 + <Link 146 + to="/$did/scrobble/$rkey" 147 + params={{ 148 + did: song.uri?.split("at://")[1]?.split("/")[0] || "", 149 + rkey: song.uri?.split("/").pop() || "", 150 + }} 151 + className="no-underline text-[var(--color-text-primary)]" 152 + > 153 + <SongCover 154 + uri={song.trackUri} 155 + cover={song.cover} 156 + artist={song.artist} 157 + title={song.title} 158 + liked={song.liked} 159 + likesCount={song.likesCount} 160 + withLikeButton 144 161 /> 162 + </Link> 163 + <div className="flex"> 164 + <div className="mr-[8px]"> 165 + <Avatar 166 + src={song.userAvatar} 167 + name={song.userDisplayName} 168 + size={"20px"} 169 + /> 170 + </div> 171 + <Handle 172 + link={`/profile/${song.user}`} 173 + did={song.user} 174 + />{" "} 145 175 </div> 146 - <Handle link={`/profile/${song.user}`} did={song.user} />{" "} 147 - </div> 148 - <LabelSmall className="!text-[var(--color-text-primary)]"> 149 - recently played this song 150 - </LabelSmall> 151 - <StatefulTooltip 152 - content={dayjs(song.date).format("MMMM D, YYYY [at] HH:mm A")} 153 - returnFocus 154 - autoFocus 155 - > 156 - <LabelSmall className="!text-[var(--color-text-muted)]"> 157 - {dayjs(song.date).fromNow()} 176 + <LabelSmall className="!text-[var(--color-text-primary)]"> 177 + recently played this song 158 178 </LabelSmall> 159 - </StatefulTooltip> 160 - </FlexGridItem> 161 - ))} 179 + <StatefulTooltip 180 + content={dayjs(song.date).format( 181 + "MMMM D, YYYY [at] HH:mm A", 182 + )} 183 + returnFocus 184 + autoFocus 185 + > 186 + <LabelSmall className="!text-[var(--color-text-muted)]"> 187 + {dayjs(song.date).fromNow()} 188 + </LabelSmall> 189 + </StatefulTooltip> 190 + </FlexGridItem> 191 + )) 192 + } 162 193 </FlexGrid> 194 + 195 + {/* Load more trigger */} 196 + <div ref={loadMoreRef} style={{ height: "20px", marginTop: "20px" }}> 197 + {isFetchingNextPage && ( 198 + <ContentLoader 199 + width={800} 200 + height={100} 201 + viewBox="0 0 800 100" 202 + backgroundColor="var(--color-skeleton-background)" 203 + foregroundColor="var(--color-skeleton-foreground)" 204 + > 205 + <rect x="12" y="10" rx="2" ry="2" width="211" height="80" /> 206 + <rect x="240" y="10" rx="2" ry="2" width="211" height="80" /> 207 + <rect x="467" y="10" rx="2" ry="2" width="211" height="80" /> 208 + </ContentLoader> 209 + )} 210 + </div> 163 211 </div> 164 212 )} 165 213 </Container>