A decentralized music tracking and discovery platform built on AT Protocol 🎵 rocksky.app
spotify atproto lastfm musicbrainz scrobbling listenbrainz
98
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add Feed Generators UI and backend algorithm

Server: add algorithm to return paginated scrobbles feed. Client: add
API endpoint, react-query hook, and FeedGenerators component (category
scroller with chevrons). Add constants and index export. Add
@tabler/icons-react dependency and small UI/style tweaks (padding,
z-index, font settings).

+348 -15
+51
apps/feeds/src/algos/all.ts
··· 1 + import { Context } from "../context.ts"; 2 + import { Algorithm, feedParams } from "./types.ts"; 3 + import schema from "../schema/mod.ts"; 4 + import { and, desc, eq, lt } from "drizzle-orm"; 5 + 6 + const handler = async ( 7 + ctx: Context, 8 + params: feedParams, 9 + _did?: string | null, 10 + ) => { 11 + const { limit = 50, cursor } = params; 12 + 13 + const whereConditions = []; 14 + 15 + if (cursor) { 16 + const cursorDate = new Date(parseInt(cursor, 10)); 17 + whereConditions.push(lt(schema.scrobbles.timestamp, cursorDate)); 18 + } 19 + 20 + const scrobbles = await ctx.db 21 + .select() 22 + .from(schema.scrobbles) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 + .where(and(...whereConditions)) 25 + .orderBy(desc(schema.scrobbles.timestamp)) 26 + .limit(limit) 27 + .execute(); 28 + 29 + const feed = scrobbles.map(({ scrobbles }) => ({ scrobble: scrobbles.uri })); 30 + 31 + const { scrobbles: lastScrobble } = 32 + scrobbles.length > 0 ? scrobbles.at(-1)! : { scrobbles: null }; 33 + const nextCursor = lastScrobble 34 + ? lastScrobble.timestamp.getTime().toString(10) 35 + : undefined; 36 + 37 + return { 38 + cursor: nextCursor, 39 + feed, 40 + }; 41 + }; 42 + 43 + export const publisherDid = "did:plc:vegqomyce4ssoqs7zwqvgqty"; 44 + export const rkey = "all"; 45 + 46 + export const info = { 47 + handler, 48 + needsAuth: false, 49 + publisherDid, 50 + rkey, 51 + } as Algorithm;
+1
apps/web/package.json
··· 35 35 "@styled-icons/remix-fill": "^10.46.0", 36 36 "@styled-icons/simple-icons": "^10.46.0", 37 37 "@styled-icons/zondicons": "^10.46.0", 38 + "@tabler/icons-react": "^3.36.0", 38 39 "@tailwindcss/vite": "^4.1.4", 39 40 "@tanstack/react-query": "^5.76.0", 40 41 "@tanstack/react-query-devtools": "^5.76.0",
+24
apps/web/src/api/feed.ts
··· 34 34 uri: response.data?.uri, 35 35 }; 36 36 }; 37 + 38 + export const getFeedGenerators = async () => { 39 + const response = await client.get<{ 40 + feeds: { 41 + id: string; 42 + name: string; 43 + uri: string; 44 + description: string; 45 + did: string; 46 + avatar?: string; 47 + creator: { 48 + avatar?: string; 49 + displayName: string; 50 + handle: string; 51 + did: string; 52 + id: string; 53 + }; 54 + }[]; 55 + }>("/xrpc/app.rocksky.feed.getFeedGenerators"); 56 + if (response.status !== 200) { 57 + return null; 58 + } 59 + return response.data; 60 + };
+7 -1
apps/web/src/hooks/useFeed.tsx
··· 1 1 import { useQuery } from "@tanstack/react-query"; 2 2 import { client } from "../api"; 3 - import { getFeedByUri } from "../api/feed"; 3 + import { getFeedByUri, getFeedGenerators } from "../api/feed"; 4 4 5 5 export const useFeedQuery = (limit = 114) => 6 6 useQuery({ ··· 17 17 queryKey: ["feed", uri], 18 18 queryFn: () => getFeedByUri(uri), 19 19 }); 20 + 21 + export const useFeedGeneratorsQuery = () => 22 + useQuery({ 23 + queryKey: ["feedGenerators"], 24 + queryFn: () => getFeedGenerators(), 25 + });
+15 -2
apps/web/src/index.css
··· 54 54 body { 55 55 margin: 0; 56 56 font-family: 57 - RockfordSansLight, -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", 58 - "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 57 + RockfordSansLight, 58 + -apple-system, 59 + BlinkMacSystemFont, 60 + "Segoe UI", 61 + "Roboto", 62 + "Oxygen", 63 + "Ubuntu", 64 + "Cantarell", 65 + "Fira Sans", 66 + "Droid Sans", 67 + "Helvetica Neue", 59 68 sans-serif; 60 69 -webkit-font-smoothing: antialiased; 61 70 -moz-osx-font-smoothing: grayscale; ··· 119 128 background-color 0.1s ease, 120 129 color 0.1s ease; 121 130 } 131 + 132 + button { 133 + font-family: RockfordSansMedium; 134 + }
+1 -1
apps/web/src/layouts/Main.tsx
··· 142 142 </Flex> 143 143 {withRightPane && ( 144 144 <RightPane className="relative w-[300px]"> 145 - <div className="fixed top-[100px] h-[calc(100vh-100px)] w-[300px] bg-white p-[20px] overflow-y-auto"> 145 + <div className="fixed top-[100px] h-[calc(100vh-100px)] w-[300px] bg-white p-[20px] overflow-y-auto pt-[0px]"> 146 146 <div className="mb-[30px]"> 147 147 <Search /> 148 148 </div>
+4 -10
apps/web/src/pages/home/feed/Feed.tsx
··· 4 4 import type { BlockProps } from "baseui/block"; 5 5 import { FlexGrid, FlexGridItem } from "baseui/flex-grid"; 6 6 import { StatefulTooltip } from "baseui/tooltip"; 7 - import { HeadingMedium, LabelSmall } from "baseui/typography"; 7 + import { LabelSmall } from "baseui/typography"; 8 8 import dayjs from "dayjs"; 9 9 import relativeTime from "dayjs/plugin/relativeTime"; 10 10 import ContentLoader from "react-content-loader"; ··· 14 14 import { useEffect, useRef } from "react"; 15 15 import { WS_URL } from "../../../consts"; 16 16 import { useQueryClient } from "@tanstack/react-query"; 17 + import FeedGenerators from "./FeedGenerators"; 17 18 18 19 dayjs.extend(relativeTime); 19 20 ··· 77 78 78 79 return ( 79 80 <Container> 80 - <HeadingMedium 81 - marginTop={"0px"} 82 - marginBottom={"25px"} 83 - className="!text-[var(--color-text)]" 84 - > 85 - Recently played 86 - </HeadingMedium> 87 - 81 + <FeedGenerators /> 88 82 {isLoading && ( 89 83 <ContentLoader 90 84 width={800} ··· 112 106 )} 113 107 114 108 {!isLoading && ( 115 - <div className="pb-[100px]"> 109 + <div className="pb-[100px] pt-[20px]"> 116 110 <FlexGrid 117 111 flexGridColumnCount={[1, 2, 3]} 118 112 flexGridColumnGap="scale800"
+182
apps/web/src/pages/home/feed/FeedGenerators/FeedGenerators.tsx
··· 1 + import { useState, useRef, useEffect } from "react"; 2 + import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react"; 3 + import { categories } from "./constants"; 4 + 5 + function FeedGenerators() { 6 + const jwt = localStorage.getItem("token"); 7 + const [activeCategory, setActiveCategory] = useState("All"); 8 + const [showLeftChevron, setShowLeftChevron] = useState(false); 9 + const [showRightChevron, setShowRightChevron] = useState(true); 10 + const [hasOverflow, setHasOverflow] = useState(false); 11 + const scrollContainerRef = useRef<HTMLDivElement>(null); 12 + 13 + // Check scroll position and update chevron visibility 14 + const handleScroll = () => { 15 + const container = scrollContainerRef.current; 16 + if (!container) return; 17 + 18 + const { scrollLeft, scrollWidth, clientWidth } = container; 19 + 20 + // Check if content overflows 21 + const overflow = scrollWidth > clientWidth; 22 + setHasOverflow(overflow); 23 + 24 + // Show left chevron if scrolled from the start 25 + setShowLeftChevron(scrollLeft > 10); 26 + 27 + // Show right chevron if not scrolled to the end 28 + setShowRightChevron(scrollLeft < scrollWidth - clientWidth - 10); 29 + }; 30 + 31 + // Scroll left/right 32 + const scroll = (direction: "left" | "right") => { 33 + const container = scrollContainerRef.current; 34 + if (!container) return; 35 + 36 + const scrollAmount = 200; 37 + const newScrollLeft = 38 + direction === "left" 39 + ? container.scrollLeft - scrollAmount 40 + : container.scrollLeft + scrollAmount; 41 + 42 + container.scrollTo({ 43 + left: newScrollLeft, 44 + behavior: "smooth", 45 + }); 46 + }; 47 + 48 + const handleCategoryClick = (category: string, index: number) => { 49 + setActiveCategory(category); 50 + 51 + const container = scrollContainerRef.current; 52 + if (container) { 53 + const buttons = container.children; 54 + const button = buttons[index] as HTMLElement; 55 + 56 + if (button) { 57 + const containerWidth = container.offsetWidth; 58 + const buttonLeft = button.offsetLeft; 59 + const buttonWidth = button.offsetWidth; 60 + 61 + // Center the clicked button 62 + const scrollPosition = 63 + buttonLeft - containerWidth / 2 + buttonWidth / 2; 64 + container.scrollTo({ 65 + left: scrollPosition, 66 + behavior: "smooth", 67 + }); 68 + } 69 + } 70 + }; 71 + 72 + // Check overflow on mount and window resize 73 + useEffect(() => { 74 + handleScroll(); 75 + 76 + const handleResize = () => { 77 + handleScroll(); 78 + }; 79 + 80 + window.addEventListener("resize", handleResize); 81 + return () => window.removeEventListener("resize", handleResize); 82 + }, []); 83 + 84 + return ( 85 + <div 86 + className={`sticky ${jwt ? "top-[80px]" : "top-[80px]"} bg-[var(--color-background)] z-50`} 87 + > 88 + <style>{` 89 + .no-scrollbar::-webkit-scrollbar { 90 + display: none; 91 + } 92 + .no-scrollbar { 93 + -ms-overflow-style: none; 94 + scrollbar-width: none; 95 + } 96 + `}</style> 97 + 98 + <div className="bg-[var(--color-background)]"> 99 + <div className="relative h-[50px] flex items-center"> 100 + {/* Left chevron */} 101 + {showLeftChevron && ( 102 + <button 103 + onClick={() => scroll("left")} 104 + className="flex-shrink-0 w-8 h-8 rounded-full bg-transparent hover:bg-[var(--color-input-background)] flex items-center justify-center transition-all outline-none border-none cursor-pointer shadow-md z-30 h-[30px] w-[30px] mt-[3px]" 105 + > 106 + <IconChevronLeft size={16} className="text-[var(--color-text)]" /> 107 + </button> 108 + )} 109 + 110 + <div 111 + className="relative flex-1 overflow-hidden" 112 + style={ 113 + hasOverflow 114 + ? { 115 + maskImage: 116 + showLeftChevron && showRightChevron 117 + ? "linear-gradient(to right, transparent, black 40px, black calc(100% - 40px), transparent)" 118 + : showLeftChevron 119 + ? "linear-gradient(to right, transparent, black 40px, black 100%)" 120 + : showRightChevron 121 + ? "linear-gradient(to right, black 0%, black calc(100% - 40px), transparent)" 122 + : undefined, 123 + WebkitMaskImage: 124 + showLeftChevron && showRightChevron 125 + ? "linear-gradient(to right, transparent, black 40px, black calc(100% - 40px), transparent)" 126 + : showLeftChevron 127 + ? "linear-gradient(to right, transparent, black 40px, black 100%)" 128 + : showRightChevron 129 + ? "linear-gradient(to right, black 0%, black calc(100% - 40px), transparent)" 130 + : undefined, 131 + } 132 + : undefined 133 + } 134 + > 135 + <div 136 + ref={scrollContainerRef} 137 + onScroll={handleScroll} 138 + className="flex gap-[8px] overflow-x-auto no-scrollbar px-4 py-3 h-full" 139 + > 140 + {categories.map((category, index) => ( 141 + <button 142 + key={category} 143 + onClick={() => handleCategoryClick(category, index)} 144 + className={` 145 + relative flex-shrink-0 px-3.5 py-1.5 rounded-full text-sm font-medium 146 + transition-all duration-200 whitespace-nowrap outline-none border-none p-[8px] pl-[12px] pr-[12px] cursor-pointer 147 + ${ 148 + activeCategory === category 149 + ? "bg-[var(--color-input-background)] text-[var(--color-text)]" 150 + : "bg-transparent text-[var(--color-text)] hover:bg-[var(--color-input-background)]" 151 + } 152 + `} 153 + > 154 + {category} 155 + {/* Active indicator underline */} 156 + {activeCategory === category && ( 157 + <div className="absolute bottom-[-12px] left-1/2 transform -translate-x-1/2 w-8 h-0.5 bg-[var(--color-primary)]" /> 158 + )} 159 + </button> 160 + ))} 161 + </div> 162 + </div> 163 + 164 + {/* Right chevron */} 165 + {showRightChevron && ( 166 + <button 167 + onClick={() => scroll("right")} 168 + className="flex-shrink-0 w-8 h-8 rounded-full bg-transparent hover:bg-[var(--color-input-background)] flex items-center justify-center transition-all outline-none border-none cursor-pointer shadow-md z-30 h-[30px] w-[30px] mt-[3px]" 169 + > 170 + <IconChevronRight 171 + size={16} 172 + className="text-[var(--color-text)]" 173 + /> 174 + </button> 175 + )} 176 + </div> 177 + </div> 178 + </div> 179 + ); 180 + } 181 + 182 + export default FeedGenerators;
+54
apps/web/src/pages/home/feed/FeedGenerators/constants.ts
··· 1 + export const categories = [ 2 + "all", 3 + "afrobeat", 4 + "afrobeats", 5 + "alternative metal", 6 + "alternative r&b", 7 + "anime", 8 + "art pop", 9 + "breakcore", 10 + "chicago drill", 11 + "chillwave", 12 + "country hip hop", 13 + "crunk", 14 + "dance pop", 15 + "deep house", 16 + "drill", 17 + "dubstep", 18 + "emo", 19 + "grunge", 20 + "hard rock", 21 + "heavy metal", 22 + "hip hop", 23 + "house", 24 + "hyperpop", 25 + "indie", 26 + "indie rock", 27 + "j-pop", 28 + "j-rock", 29 + "jazz", 30 + "k-pop", 31 + "lo-fi", 32 + "metal", 33 + "metalcore", 34 + "midwest emo", 35 + "nu metal", 36 + "pop punk", 37 + "post-grunge", 38 + "rap", 39 + "rap metal", 40 + "r&b", 41 + "rock", 42 + "southern hip hop", 43 + "speedcore", 44 + "swedish pop", 45 + "synthwave", 46 + "thrash metal", 47 + "trap", 48 + "trap soul", 49 + "tropical house", 50 + "vaporwave", 51 + "visual kei", 52 + "vocaloid", 53 + "west coast hip hop", 54 + ];
+3
apps/web/src/pages/home/feed/FeedGenerators/index.tsx
··· 1 + import FeedGenerators from "./FeedGenerators"; 2 + 3 + export default FeedGenerators;
+1 -1
apps/web/src/pages/home/nowplayings/styles.tsx
··· 2 2 modal: { 3 3 Root: { 4 4 style: { 5 - zIndex: 2, 5 + zIndex: 60, 6 6 }, 7 7 }, 8 8 Dialog: {
+5
bun.lock
··· 186 186 "@styled-icons/remix-fill": "^10.46.0", 187 187 "@styled-icons/simple-icons": "^10.46.0", 188 188 "@styled-icons/zondicons": "^10.46.0", 189 + "@tabler/icons-react": "^3.36.0", 189 190 "@tailwindcss/vite": "^4.1.4", 190 191 "@tanstack/react-query": "^5.76.0", 191 192 "@tanstack/react-query-devtools": "^5.76.0", ··· 1152 1153 "@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="], 1153 1154 1154 1155 "@swc/types": ["@swc/types@0.1.25", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g=="], 1156 + 1157 + "@tabler/icons": ["@tabler/icons@3.36.0", "", {}, "sha512-z9OfTEG6QbaQWM9KBOxxUdpgvMUn0atageXyiaSc2gmYm51ORO8Ua7eUcjlks+Dc0YMK4rrodAFdK9SfjJ4ZcA=="], 1158 + 1159 + "@tabler/icons-react": ["@tabler/icons-react@3.36.0", "", { "dependencies": { "@tabler/icons": "3.36.0" }, "peerDependencies": { "react": ">= 16" } }, "sha512-sSZ00bEjTdTTskVFykq294RJq+9cFatwy4uYa78HcYBCXU1kSD1DIp5yoFsQXmybkIOKCjp18OnhAYk553UIfQ=="], 1155 1160 1156 1161 "@tailwindcss/node": ["@tailwindcss/node@4.1.14", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.0", "lightningcss": "1.30.1", "magic-string": "^0.30.19", "source-map-js": "^1.2.1", "tailwindcss": "4.1.14" } }, "sha512-hpz+8vFk3Ic2xssIA3e01R6jkmsAhvkQdXlEbRTk6S10xDAtiQiM3FyvZVGsucefq764euO/b8WUW9ysLdThHw=="], 1157 1162