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

Add DID filter for getFollowers and follow UI

Support optional "dids" array query param in lexicon/schema and server
handler (use inArray) to filter followers. Add web client endpoints,
React hooks, follows atom, and follow/unfollow mutations plus a Follow
button in the profile UI.

+254 -44
+8
apps/api/lexicons/graph/getFollowers.json
··· 21 21 "minimum": 1, 22 22 "default": 50 23 23 }, 24 + "dids": { 25 + "type": "array", 26 + "description": "If provided, filters the followers to only include those with DIDs in this list.", 27 + "items": { 28 + "type": "string", 29 + "format": "did" 30 + } 31 + }, 24 32 "cursor": { 25 33 "type": "string" 26 34 }
+9
apps/api/pkl/defs/graph/getFollowers.pkl
··· 19 19 minimum = 1 20 20 default = 50 21 21 } 22 + ["dids"] = new Array { 23 + type = "array" 24 + items = new StringType { 25 + type = "string" 26 + format = "did" 27 + } 28 + description = 29 + "If provided, filters the followers to only include those with DIDs in this list." 30 + } 22 31 ["cursor"] = new StringType { 23 32 type = "string" 24 33 }
+9
apps/api/src/lexicon/lexicons.ts
··· 2879 2879 minimum: 1, 2880 2880 default: 50, 2881 2881 }, 2882 + dids: { 2883 + type: "array", 2884 + description: 2885 + "If provided, filters the followers to only include those with DIDs in this list.", 2886 + items: { 2887 + type: "string", 2888 + format: "did", 2889 + }, 2890 + }, 2882 2891 cursor: { 2883 2892 type: "string", 2884 2893 },
+2
apps/api/src/lexicon/types/app/rocksky/graph/getFollowers.ts
··· 12 12 export interface QueryParams { 13 13 actor: string; 14 14 limit: number; 15 + /** If provided, filters the followers to only include those with DIDs in this list. */ 16 + dids?: string[]; 15 17 cursor?: string; 16 18 } 17 19
+27 -7
apps/api/src/xrpc/app/rocksky/graph/getFollowers.ts
··· 1 1 import type { Context } from "context"; 2 - import { eq, desc, and, lt } from "drizzle-orm"; 2 + import { eq, desc, and, lt, inArray } from "drizzle-orm"; 3 3 import { Effect, pipe } from "effect"; 4 4 import type { Server } from "lexicon"; 5 5 import type { QueryParams } from "lexicon/types/app/rocksky/graph/getFollowers"; ··· 59 59 .where( 60 60 params.cursor 61 61 ? and( 62 - lt(tables.follows.createdAt, new Date(params.cursor)), 63 - eq(tables.follows.subject_did, params.actor), 62 + ...[ 63 + lt(tables.follows.createdAt, new Date(params.cursor)), 64 + eq(tables.follows.subject_did, params.actor), 65 + (params.dids || params.dids.length > 0) && 66 + inArray(tables.follows.follower_did, params.dids), 67 + ], 64 68 ) 65 - : eq(tables.follows.subject_did, params.actor), 69 + : and( 70 + ...[ 71 + eq(tables.follows.subject_did, params.actor), 72 + (params.dids || params.dids.length > 0) && 73 + inArray(tables.follows.follower_did, params.dids), 74 + ], 75 + ), 66 76 ) 67 77 .leftJoin( 68 78 tables.users, ··· 78 88 .where( 79 89 params.cursor 80 90 ? and( 81 - lt(tables.follows.createdAt, new Date(params.cursor)), 82 - eq(tables.follows.subject_did, params.actor), 91 + ...[ 92 + lt(tables.follows.createdAt, new Date(params.cursor)), 93 + eq(tables.follows.subject_did, params.actor), 94 + (params.dids || params.dids.length > 0) && 95 + inArray(tables.follows.follower_did, params.dids), 96 + ], 83 97 ) 84 - : eq(tables.follows.subject_did, params.actor), 98 + : and( 99 + ...[ 100 + eq(tables.follows.subject_did, params.actor), 101 + (params.dids || params.dids.length > 0) && 102 + inArray(tables.follows.follower_did, params.dids), 103 + ], 104 + ), 85 105 ) 86 106 .orderBy(desc(tables.follows.createdAt)) 87 107 .limit(params.limit ?? 50)
+65
apps/web/src/api/graph.ts
··· 1 + import { client } from "."; 2 + 3 + export const getFollows = async ( 4 + actor: string, 5 + limit: number, 6 + cursor?: string, 7 + ) => { 8 + const response = await client.get("/xrpc/app.rocksky.graph.getFollows", { 9 + params: { actor, limit, cursor }, 10 + }); 11 + 12 + return response.data; 13 + }; 14 + 15 + export const getKnownFollowers = async ( 16 + actor: string, 17 + limit: number, 18 + cursor?: string, 19 + ) => { 20 + const response = await client.get( 21 + "/xrpc/app.rocksky.graph.getKnownFollowers", 22 + { 23 + params: { actor, limit, cursor }, 24 + }, 25 + ); 26 + 27 + return response.data; 28 + }; 29 + 30 + export const getFollowers = async ( 31 + actor: string, 32 + limit: number, 33 + cursor?: string, 34 + ) => { 35 + const response = await client.get("/xrpc/app.rocksky.graph.getFollowers", { 36 + params: { actor, limit, cursor }, 37 + }); 38 + 39 + return response.data; 40 + }; 41 + 42 + export const followAccount = async (account: string) => { 43 + const response = await client.post("/xrpc/app.rocksky.graph.followAccount", { 44 + params: { account }, 45 + headers: { 46 + Authorization: `Bearer ${localStorage.getItem("token")}`, 47 + }, 48 + }); 49 + 50 + return response.data; 51 + }; 52 + 53 + export const unfollowAccount = async (account: string) => { 54 + const response = await client.post( 55 + "/xrpc/app.rocksky.graph.unfollowAccount", 56 + { 57 + params: { account }, 58 + headers: { 59 + Authorization: `Bearer ${localStorage.getItem("token")}`, 60 + }, 61 + }, 62 + ); 63 + 64 + return response.data; 65 + };
+5
apps/web/src/atoms/follows.ts
··· 1 + import { atom } from "jotai"; 2 + 3 + export const followsAtom = atom<{ 4 + [key: string]: string[] | null; 5 + }>({});
+49
apps/web/src/hooks/useGraph.tsx
··· 1 + import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 2 + import { 3 + getFollowers, 4 + getFollows, 5 + followAccount, 6 + unfollowAccount, 7 + } from "../api/graph"; 8 + 9 + export const useFollowsQuery = ( 10 + actor: string, 11 + limit: number, 12 + cursor?: string, 13 + ) => 14 + useQuery({ 15 + queryKey: ["follows", actor, limit, cursor], 16 + queryFn: () => getFollows(actor, limit, cursor), 17 + }); 18 + 19 + export const useFollowersQuery = ( 20 + actor: string, 21 + limit: number, 22 + cursor?: string, 23 + ) => 24 + useQuery({ 25 + queryKey: ["followers", actor, limit, cursor], 26 + queryFn: () => getFollowers(actor, limit, cursor), 27 + }); 28 + 29 + export const useFollowAccountMutation = () => { 30 + const queryClient = useQueryClient(); 31 + return useMutation({ 32 + mutationFn: followAccount, 33 + onSuccess: (_data, account) => { 34 + queryClient.invalidateQueries({ queryKey: ["follows"] }); 35 + queryClient.invalidateQueries({ queryKey: ["followers", account] }); 36 + }, 37 + }); 38 + }; 39 + 40 + export const useUnfollowAccountMutation = () => { 41 + const queryClient = useQueryClient(); 42 + return useMutation({ 43 + mutationFn: unfollowAccount, 44 + onSuccess: (_data, account) => { 45 + queryClient.invalidateQueries({ queryKey: ["follows"] }); 46 + queryClient.invalidateQueries({ queryKey: ["followers", account] }); 47 + }, 48 + }); 49 + };
+80 -37
apps/web/src/pages/profile/Profile.tsx
··· 17 17 import LovedTracks from "./lovedtracks"; 18 18 import Overview from "./overview"; 19 19 import Playlists from "./playlists"; 20 + import { Button } from "baseui/button"; 21 + import { IconPlus } from "@tabler/icons-react"; 20 22 21 23 const Group = styled.div` 22 24 display: flex; 23 25 flex-direction: row; 26 + justify-content: space-between; 27 + align-items: flex-start; 24 28 margin-top: 20px; 25 29 margin-bottom: 50px; 30 + `; 31 + 32 + const ProfileInfo = styled.div` 33 + display: flex; 34 + flex-direction: row; 35 + flex: 1; 26 36 `; 27 37 28 38 export type ProfileProps = { ··· 86 96 return; 87 97 } 88 98 99 + console.log( 100 + ">>", 101 + profile.data?.did !== localStorage.getItem("did"), 102 + profile.data?.did, 103 + localStorage.getItem("did"), 104 + ); 105 + 89 106 return ( 90 107 <Main> 91 108 <div className="pb-[100px] pt-[75px]"> 92 109 <Group> 93 - <div className="mr-[20px]"> 94 - <Avatar 95 - name={profiles[did]?.displayName} 96 - src={profiles[did]?.avatar} 97 - size="150px" 98 - /> 99 - </div> 100 - <div style={{ marginTop: profiles[did]?.displayName ? 10 : 30 }}> 101 - <HeadingMedium 102 - marginTop="0px" 103 - marginBottom={0} 104 - className="!text-[var(--color-text)]" 105 - > 106 - {profiles[did]?.displayName} 107 - </HeadingMedium> 108 - <LabelLarge> 109 - <a 110 - href={`https://bsky.app/profile/${profiles[did]?.handle}`} 111 - className="no-underline text-[var(--color-primary)]" 110 + <ProfileInfo> 111 + <div className="mr-[20px]"> 112 + <Avatar 113 + name={profiles[did]?.displayName} 114 + src={profiles[did]?.avatar} 115 + size="150px" 116 + /> 117 + </div> 118 + <div style={{ marginTop: profiles[did]?.displayName ? 10 : 30 }}> 119 + <HeadingMedium 120 + marginTop="0px" 121 + marginBottom={0} 122 + className="!text-[var(--color-text)]" 112 123 > 113 - @{profiles[did]?.handle} 114 - </a> 115 - <span className="text-[var(--color-text-muted)] text-[15px]"> 116 - {" "} 117 - • scrobbling since{" "} 118 - {dayjs(profiles[did]?.createdAt).format("DD MMM YYYY")} 119 - </span> 120 - </LabelLarge> 121 - <div className="flex-1 mt-[30px] mr-[10px]"> 122 - <a 123 - href={`https://pdsls.dev/at/${profiles[did]?.did}`} 124 - target="_blank" 125 - className="no-underline text-[var(--color-text)] bg-[var(--color-default-button)] p-[16px] rounded-[10px] pl-[25px] pr-[25px]" 126 - > 127 - <ExternalLink size={24} style={{ marginRight: 10 }} /> 128 - View on PDSls 129 - </a> 124 + {profiles[did]?.displayName} 125 + </HeadingMedium> 126 + <LabelLarge> 127 + <a 128 + href={`https://bsky.app/profile/${profiles[did]?.handle}`} 129 + className="no-underline text-[var(--color-primary)]" 130 + > 131 + @{profiles[did]?.handle} 132 + </a> 133 + <span className="text-[var(--color-text-muted)] text-[15px]"> 134 + {" "} 135 + • scrobbling since{" "} 136 + {dayjs(profiles[did]?.createdAt).format("DD MMM YYYY")} 137 + </span> 138 + </LabelLarge> 139 + <div className="flex-1 mt-[30px] mr-[10px]"> 140 + <a 141 + href={`https://pdsls.dev/at/${profiles[did]?.did}`} 142 + target="_blank" 143 + className="no-underline text-[var(--color-text)] bg-[var(--color-default-button)] p-[16px] rounded-[10px] pl-[25px] pr-[25px]" 144 + > 145 + <ExternalLink size={24} style={{ marginRight: 10 }} /> 146 + View on PDSls 147 + </a> 148 + </div> 130 149 </div> 131 - </div> 150 + </ProfileInfo> 151 + {(profile.data?.did !== localStorage.getItem("did") || 152 + !localStorage.getItem("did")) && ( 153 + <Button 154 + shape="pill" 155 + size="compact" 156 + startEnhancer={<IconPlus size={18} />} 157 + overrides={{ 158 + BaseButton: { 159 + style: { 160 + marginTop: "12px", 161 + backgroundColor: "#ff2876", 162 + ":hover": { 163 + backgroundColor: "#ff2876", 164 + }, 165 + ":focus": { 166 + backgroundColor: "#ff2876", 167 + }, 168 + }, 169 + }, 170 + }} 171 + > 172 + Follow 173 + </Button> 174 + )} 132 175 </Group> 133 176 134 177 <Tabs