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

Improve feed handling and generators

Use per-feed query keys (["feed", feed]) and update invalidation to
include the feed URI. Add feedUrisAtom and feedGeneratorUriAtom and
populate feed URIs from the feed generators API; set the selected
generator URI when a category is clicked. Make feed item rendering safer
(data?.map), adjust avatar/handle layout, and add a tooltip for
formatted dates. Fix genre string to "rap metal". Bump ramda and
@types/ramda and update bun.lock accordingly.

+82 -54
+1 -1
apps/feeds/src/algos/rap-metal.ts
··· 10 10 ) => { 11 11 const { limit = 50, cursor } = params; 12 12 13 - const whereConditions = [arrayContains(schema.artists.genres, ["rap-metal"])]; 13 + const whereConditions = [arrayContains(schema.artists.genres, ["rap metal"])]; 14 14 15 15 if (cursor) { 16 16 const cursorDate = new Date(parseInt(cursor, 10));
+2 -2
apps/web/package.json
··· 57 57 "lodash": "^4.17.21", 58 58 "numeral": "^2.0.6", 59 59 "posthog-js": "^1.234.6", 60 - "ramda": "^0.30.1", 60 + "ramda": "^0.32.0", 61 61 "react": "^18.3.1", 62 62 "react-content-loader": "^7.0.2", 63 63 "react-dom": "^18.3.1", ··· 85 85 "@storybook/test": "^8.5.2", 86 86 "@tanstack/router-plugin": "^1.125.4", 87 87 "@types/lodash": "^4.17.15", 88 - "@types/ramda": "^0.30.2", 88 + "@types/ramda": "^0.31.1", 89 89 "@types/react": "^18.3.18", 90 90 "@types/react-dom": "^18.3.5", 91 91 "@vitejs/plugin-react-swc": "^3.5.0",
+4
apps/web/src/atoms/feed.ts
··· 5 5 export const feedGeneratorUriAtom = atom<string>( 6 6 "at://did:plc:vegqomyce4ssoqs7zwqvgqty/app.rocksky.feed.generator/all", 7 7 ); 8 + 9 + export const feedUrisAtom = atom<Record<string, string>>({ 10 + all: "at://did:plc:vegqomyce4ssoqs7zwqvgqty/app.rocksky.feed/all", 11 + });
+1 -1
apps/web/src/hooks/useFeed.tsx
··· 3 3 4 4 export const useFeedQuery = (feed: string, limit = 114, cursor?: string) => 5 5 useQuery({ 6 - queryKey: ["feed"], 6 + queryKey: ["feed", feed], 7 7 queryFn: () => getFeed(feed, limit, cursor), 8 8 }); 9 9
+39 -46
apps/web/src/pages/home/feed/Feed.tsx
··· 62 62 () => message.scrobblesChart, 63 63 ); 64 64 65 - await queryClient.invalidateQueries({ queryKey: ["feed"] }); 65 + await queryClient.invalidateQueries({ queryKey: ["feed", feedUri] }); 66 66 await queryClient.invalidateQueries({ queryKey: ["now-playings"] }); 67 67 await queryClient.invalidateQueries({ queryKey: ["scrobblesChart"] }); 68 68 }; ··· 114 114 flexGridColumnGap="scale800" 115 115 flexGridRowGap="scale1000" 116 116 > 117 - { 118 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 119 - data.map((song: any) => ( 120 - <FlexGridItem {...itemProps} key={song.id}> 121 - <Link 122 - to="/$did/scrobble/$rkey" 123 - params={{ 124 - did: song.uri?.split("at://")[1]?.split("/")[0] || "", 125 - rkey: song.uri?.split("/").pop() || "", 126 - }} 127 - className="no-underline text-[var(--color-text-primary)]" 128 - > 129 - <SongCover 130 - cover={song.cover} 131 - artist={song.artist} 132 - title={song.title} 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 + cover={song.cover} 130 + artist={song.artist} 131 + title={song.title} 132 + /> 133 + </Link> 134 + <div className="flex"> 135 + <div className="mr-[8px]"> 136 + <Avatar 137 + src={song.userAvatar} 138 + name={song.userDisplayName} 139 + size={"20px"} 133 140 /> 134 - </Link> 135 - <div className="flex"> 136 - <div className="mr-[8px]"> 137 - <Avatar 138 - src={song.userAvatar} 139 - name={song.userDisplayName} 140 - size={"20px"} 141 - /> 142 - </div> 143 - <Handle 144 - link={`/profile/${song.user}`} 145 - did={song.user} 146 - />{" "} 147 141 </div> 148 - <LabelSmall className="!text-[var(--color-text-primary)]"> 149 - recently played this song 142 + <Handle link={`/profile/${song.user}`} did={song.user} />{" "} 143 + </div> 144 + <LabelSmall className="!text-[var(--color-text-primary)]"> 145 + recently played this song 146 + </LabelSmall> 147 + <StatefulTooltip 148 + content={dayjs(song.date).format("MMMM D, YYYY [at] HH:mm A")} 149 + returnFocus 150 + autoFocus 151 + > 152 + <LabelSmall className="!text-[var(--color-text-muted)]"> 153 + {dayjs(song.date).fromNow()} 150 154 </LabelSmall> 151 - <StatefulTooltip 152 - content={dayjs(song.date).format( 153 - "MMMM D, YYYY [at] HH:mm A", 154 - )} 155 - returnFocus 156 - autoFocus 157 - > 158 - <LabelSmall className="!text-[var(--color-text-muted)]"> 159 - {dayjs(song.date).fromNow()} 160 - </LabelSmall> 161 - </StatefulTooltip> 162 - </FlexGridItem> 163 - )) 164 - } 155 + </StatefulTooltip> 156 + </FlexGridItem> 157 + ))} 165 158 </FlexGrid> 166 159 </div> 167 160 )}
+27 -1
apps/web/src/pages/home/feed/FeedGenerators/FeedGenerators.tsx
··· 2 2 import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react"; 3 3 import { categories } from "./constants"; 4 4 import { useAtom } from "jotai"; 5 - import { feedAtom } from "../../../../atoms/feed"; 5 + import { 6 + feedAtom, 7 + feedGeneratorUriAtom, 8 + feedUrisAtom, 9 + } from "../../../../atoms/feed"; 10 + import { useFeedGeneratorsQuery } from "../../../../hooks/useFeed"; 11 + import * as R from "ramda"; 6 12 7 13 function FeedGenerators() { 8 14 const jwt = localStorage.getItem("token"); 15 + const { data: feedGenerators } = useFeedGeneratorsQuery(); 16 + const [feedUris, setFeedUris] = useAtom(feedUrisAtom); 17 + const [, setFeedUri] = useAtom(feedGeneratorUriAtom); 9 18 const [activeCategory, setActiveCategory] = useAtom(feedAtom); 10 19 const [showLeftChevron, setShowLeftChevron] = useState(false); 11 20 const [showRightChevron, setShowRightChevron] = useState(true); 12 21 const [hasOverflow, setHasOverflow] = useState(false); 13 22 const scrollContainerRef = useRef<HTMLDivElement>(null); 23 + 24 + useEffect(() => { 25 + if (!feedGenerators?.feeds) { 26 + return; 27 + } 28 + const feedRegistry = R.indexBy( 29 + R.prop("name"), 30 + feedGenerators.feeds 31 + .map((x) => ({ 32 + ...x, 33 + name: x.name.toLowerCase(), 34 + })) 35 + .filter((x) => categories.includes(x.name)), 36 + ); 37 + setFeedUris(R.map(R.prop("uri"), feedRegistry)); 38 + }, [feedGenerators, setFeedUris]); 14 39 15 40 // Check scroll position and update chevron visibility 16 41 const handleScroll = () => { ··· 49 74 50 75 const handleCategoryClick = (category: string, index: number) => { 51 76 setActiveCategory(category); 77 + setFeedUri(feedUris[category]); 52 78 53 79 const container = scrollContainerRef.current; 54 80 if (container) {
-1
apps/web/src/pages/home/feed/FeedGenerators/constants.ts
··· 3 3 "afrobeat", 4 4 "afrobeats", 5 5 "alternative metal", 6 - "alternative r&b", 7 6 "anime", 8 7 "art pop", 9 8 "breakcore",
+8 -2
bun.lock
··· 208 208 "lodash": "^4.17.21", 209 209 "numeral": "^2.0.6", 210 210 "posthog-js": "^1.234.6", 211 - "ramda": "^0.30.1", 211 + "ramda": "^0.32.0", 212 212 "react": "^18.3.1", 213 213 "react-content-loader": "^7.0.2", 214 214 "react-dom": "^18.3.1", ··· 236 236 "@storybook/test": "^8.5.2", 237 237 "@tanstack/router-plugin": "^1.125.4", 238 238 "@types/lodash": "^4.17.15", 239 - "@types/ramda": "^0.30.2", 239 + "@types/ramda": "^0.31.1", 240 240 "@types/react": "^18.3.18", 241 241 "@types/react-dom": "^18.3.5", 242 242 "@vitejs/plugin-react-swc": "^3.5.0", ··· 3054 3054 3055 3055 "@rocksky/spotify-proxy/wrangler": ["wrangler@4.42.2", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.0", "@cloudflare/unenv-preset": "2.7.7", "blake3-wasm": "2.1.5", "esbuild": "0.25.4", "miniflare": "4.20251008.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.21", "workerd": "1.20251008.0" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20251008.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-1iTnbjB4F12KSP1zbfxQL495xarS+vdrZnulQP2SEcAxDTUGn7N9zk1O2WtFOc+Fhcgl+9/sdz/4AL9pF34Pwg=="], 3056 3056 3057 + "@rocksky/web/@types/ramda": ["@types/ramda@0.31.1", "", { "dependencies": { "types-ramda": "^0.31.0" } }, "sha512-Vt6sFXnuRpzaEj+yeutA0q3bcAsK7wdPuASIzR9LXqL4gJPyFw8im9qchlbp4ltuf3kDEIRmPJTD/Fkg60dn7g=="], 3058 + 3059 + "@rocksky/web/ramda": ["ramda@0.32.0", "", {}, "sha512-GQWAHhxhxWBWA8oIBr1XahFVjQ9Fic6MK9ikijfd4TZHfE2+urfk+irVlR5VOn48uwMgM+loRRBJd6Yjsbc0zQ=="], 3060 + 3057 3061 "@rocksky/web/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], 3058 3062 3059 3063 "@rocksky/web/vitest": ["vitest@3.0.9", "", { "dependencies": { "@vitest/expect": "3.0.9", "@vitest/mocker": "3.0.9", "@vitest/pretty-format": "^3.0.9", "@vitest/runner": "3.0.9", "@vitest/snapshot": "3.0.9", "@vitest/spy": "3.0.9", "@vitest/utils": "3.0.9", "chai": "^5.2.0", "debug": "^4.4.0", "expect-type": "^1.1.0", "magic-string": "^0.30.17", "pathe": "^2.0.3", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinypool": "^1.0.2", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0", "vite-node": "3.0.9", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.0.9", "@vitest/ui": "3.0.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-BbcFDqNyBlfSpATmTtXOAOj71RNKDDvjBM/uPfnxxVGrG+FSH2RQIwgeEngTaTkuU/h0ScFvf+tRcKfYXzBybQ=="], ··· 3501 3505 "@rocksky/web-mobile/vitest/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], 3502 3506 3503 3507 "@rocksky/web-mobile/vitest/vite-node": ["vite-node@3.0.9", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.0", "es-module-lexer": "^1.6.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-w3Gdx7jDcuT9cNn9jExXgOyKmf5UOTb6WMHz8LGAm54eS1Elf5OuBhCxl6zJxGhEeIkgsE1WbHuoL0mj/UXqXg=="], 3508 + 3509 + "@rocksky/web/@types/ramda/types-ramda": ["types-ramda@0.31.0", "", { "dependencies": { "ts-toolbelt": "^9.6.0" } }, "sha512-vaoC35CRC3xvL8Z6HkshDbi6KWM1ezK0LHN0YyxXWUn9HKzBNg/T3xSGlJZjCYspnOD3jE7bcizsp0bUXZDxnQ=="], 3504 3510 3505 3511 "@rocksky/web/vitest/@vitest/expect": ["@vitest/expect@3.0.9", "", { "dependencies": { "@vitest/spy": "3.0.9", "@vitest/utils": "3.0.9", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-5eCqRItYgIML7NNVgJj6TVCmdzE7ZVgJhruW0ziSQV4V7PvLkDL1bBkBdcTs/VuIz0IxPb5da1IDSqc1TR9eig=="], 3506 3512