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

Add dids filter and followers/follows UI

Add optional "dids" query param to getFollows lexicon and server logic;
use inArray only when dids are provided and parse cursor via
Number(cursor). Wire frontend to pass dids and sanitize limit. Replace
followsAtom with a Set for efficient membership checks, add Followers
and Follows pages with infinite scroll, add activeTab atom, and add
SignInModal follow support. Also minor style tweak to default button
color.

+921 -127
+8
apps/api/lexicons/graph/getFollows.json
··· 21 21 "minimum": 1, 22 22 "default": 50 23 23 }, 24 + "dids": { 25 + "type": "array", 26 + "description": "If provided, filters the follows 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/getFollows.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 follows 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
··· 2943 2943 minimum: 1, 2944 2944 default: 50, 2945 2945 }, 2946 + dids: { 2947 + type: "array", 2948 + description: 2949 + "If provided, filters the follows to only include those with DIDs in this list.", 2950 + items: { 2951 + type: "string", 2952 + format: "did", 2953 + }, 2954 + }, 2946 2955 cursor: { 2947 2956 type: "string", 2948 2957 },
+2
apps/api/src/lexicon/types/app/rocksky/graph/getFollows.ts
··· 12 12 export interface QueryParams { 13 13 actor: string; 14 14 limit: number; 15 + /** If provided, filters the follows to only include those with DIDs in this list. */ 16 + dids?: string[]; 15 17 cursor?: string; 16 18 } 17 19
+19 -9
apps/api/src/xrpc/app/rocksky/graph/getFollowers.ts
··· 60 60 params.cursor 61 61 ? and( 62 62 ...[ 63 - lt(tables.follows.createdAt, new Date(params.cursor)), 63 + lt( 64 + tables.follows.createdAt, 65 + new Date(Number(params.cursor)), 66 + ), 64 67 eq(tables.follows.subject_did, params.actor), 65 - (params.dids || params.dids.length > 0) && 66 - inArray(tables.follows.follower_did, params.dids), 68 + params.dids && params.dids?.length > 0 69 + ? inArray(tables.follows.follower_did, params.dids) 70 + : undefined, 67 71 ], 68 72 ) 69 73 : and( 70 74 ...[ 71 75 eq(tables.follows.subject_did, params.actor), 72 - (params.dids || params.dids.length > 0) && 73 - inArray(tables.follows.follower_did, params.dids), 76 + params.dids && params.dids?.length > 0 77 + ? inArray(tables.follows.follower_did, params.dids) 78 + : undefined, 74 79 ], 75 80 ), 76 81 ) ··· 89 94 params.cursor 90 95 ? and( 91 96 ...[ 92 - lt(tables.follows.createdAt, new Date(params.cursor)), 97 + lt( 98 + tables.follows.createdAt, 99 + new Date(Number(params.cursor)), 100 + ), 93 101 eq(tables.follows.subject_did, params.actor), 94 - (params.dids || params.dids.length > 0) && 102 + params.dids && 103 + params.dids?.length > 0 && 95 104 inArray(tables.follows.follower_did, params.dids), 96 105 ], 97 106 ) 98 107 : and( 99 108 ...[ 100 109 eq(tables.follows.subject_did, params.actor), 101 - (params.dids || params.dids.length > 0) && 102 - inArray(tables.follows.follower_did, params.dids), 110 + params.dids && params.dids?.length > 0 111 + ? inArray(tables.follows.follower_did, params.dids) 112 + : undefined, 103 113 ], 104 114 ), 105 115 )
+24 -19
apps/api/src/xrpc/app/rocksky/graph/getFollows.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"; ··· 46 46 [SelectUser | undefined, SelectUser[], string | undefined], 47 47 Error 48 48 > => { 49 + // Build where conditions dynamically 50 + const buildWhereConditions = () => { 51 + const conditions = [eq(tables.follows.follower_did, params.actor)]; 52 + 53 + if (params.cursor) { 54 + conditions.push( 55 + lt(tables.follows.createdAt, new Date(Number(params.cursor))), 56 + ); 57 + } 58 + 59 + if (params.dids && params.dids.length > 0) { 60 + conditions.push(inArray(tables.follows.subject_did, params.dids)); 61 + } 62 + 63 + return conditions.length > 1 ? and(...conditions) : conditions[0]; 64 + }; 65 + 66 + const whereConditions = buildWhereConditions(); 67 + 49 68 return Effect.tryPromise({ 50 69 try: () => 51 70 Promise.all([ ··· 58 77 ctx.db 59 78 .select() 60 79 .from(tables.follows) 61 - .where( 62 - params.cursor 63 - ? and( 64 - lt(tables.follows.createdAt, new Date(params.cursor)), 65 - eq(tables.follows.follower_did, params.actor), 66 - ) 67 - : eq(tables.follows.follower_did, params.actor), 68 - ) 80 + .where(whereConditions) 69 81 .leftJoin( 70 82 tables.users, 71 - eq(tables.users.did, tables.follows.follower_did), 83 + eq(tables.users.did, tables.follows.subject_did), 72 84 ) 73 85 .orderBy(desc(tables.follows.createdAt)) 74 86 .limit(params.limit ?? 50) ··· 77 89 ctx.db 78 90 .select() 79 91 .from(tables.follows) 80 - .where( 81 - params.cursor 82 - ? and( 83 - lt(tables.follows.createdAt, new Date(params.cursor)), 84 - eq(tables.follows.follower_did, params.actor), 85 - ) 86 - : eq(tables.follows.follower_did, params.actor), 87 - ) 92 + .where(whereConditions) 88 93 .orderBy(desc(tables.follows.createdAt)) 89 94 .limit(params.limit ?? 50) 90 95 .execute() 91 96 .then((rows) => 92 - rows.length > 0 97 + rows?.length > 0 93 98 ? rows[rows.length - 1]?.createdAt.getTime().toString(10) 94 99 : undefined, 95 100 ),
+2 -2
apps/api/src/xrpc/app/rocksky/graph/getKnownFollowers.ts
··· 71 71 .where( 72 72 params.cursor 73 73 ? and( 74 - lt(tables.follows.createdAt, new Date(params.cursor)), 74 + lt(tables.follows.createdAt, new Date(Number(params.cursor))), 75 75 eq(tables.follows.subject_did, params.actor), 76 76 sql`EXISTS ( 77 77 SELECT 1 FROM ${tables.follows} f2 ··· 92 92 .limit(params.limit ?? 50) 93 93 .execute(); 94 94 const cursor = 95 - knownFollowers.length > 0 95 + knownFollowers?.length > 0 96 96 ? knownFollowers[knownFollowers.length - 1].follows.createdAt 97 97 .getTime() 98 98 .toString(10)
+15 -8
apps/web/src/api/graph.ts
··· 3 3 export const getFollows = async ( 4 4 actor: string, 5 5 limit: number, 6 + dids?: string[], 6 7 cursor?: string, 7 8 ) => { 8 9 const response = await client.get("/xrpc/app.rocksky.graph.getFollows", { 9 - params: { actor, limit, cursor }, 10 + params: { actor, limit: limit > 0 ? limit : 1, dids, cursor }, 10 11 }); 11 12 12 13 return response.data; ··· 20 21 const response = await client.get( 21 22 "/xrpc/app.rocksky.graph.getKnownFollowers", 22 23 { 23 - params: { actor, limit, cursor }, 24 + params: { actor, limit: limit > 0 ? limit : 1, cursor }, 24 25 }, 25 26 ); 26 27 ··· 30 31 export const getFollowers = async ( 31 32 actor: string, 32 33 limit: number, 34 + dids?: string[], 33 35 cursor?: string, 34 36 ) => { 35 37 const response = await client.get("/xrpc/app.rocksky.graph.getFollowers", { 36 - params: { actor, limit, cursor }, 38 + params: { actor, limit: limit > 0 ? limit : 1, dids, cursor }, 37 39 }); 38 40 39 41 return response.data; 40 42 }; 41 43 42 44 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")}`, 45 + const response = await client.post( 46 + "/xrpc/app.rocksky.graph.followAccount", 47 + undefined, 48 + { 49 + params: { account }, 50 + headers: { 51 + Authorization: `Bearer ${localStorage.getItem("token")}`, 52 + }, 47 53 }, 48 - }); 54 + ); 49 55 50 56 return response.data; 51 57 }; ··· 53 59 export const unfollowAccount = async (account: string) => { 54 60 const response = await client.post( 55 61 "/xrpc/app.rocksky.graph.unfollowAccount", 62 + undefined, 56 63 { 57 64 params: { account }, 58 65 headers: {
+1 -3
apps/web/src/atoms/follows.ts
··· 1 1 import { atom } from "jotai"; 2 2 3 - export const followsAtom = atom<{ 4 - [key: string]: string[] | null; 5 - }>({}); 3 + export const followsAtom = atom<Set<string>>(new Set<string>());
+4
apps/web/src/atoms/tab.ts
··· 1 + import { atom } from "jotai"; 2 + import { Key } from "react"; 3 + 4 + export const activeTabAtom = atom<Key>("0");
+167 -44
apps/web/src/components/Handle/Handle.tsx
··· 4 4 import { StatefulPopover, TRIGGER_TYPE } from "baseui/popover"; 5 5 import { LabelMedium, LabelSmall } from "baseui/typography"; 6 6 import { useAtom } from "jotai"; 7 - import { useEffect } from "react"; 7 + import { useEffect, useState } from "react"; 8 8 import { profilesAtom } from "../../atoms/profiles"; 9 9 import { statsAtom } from "../../atoms/stats"; 10 10 import { ··· 13 13 } from "../../hooks/useProfile"; 14 14 import Stats from "../Stats"; 15 15 import NowPlaying from "./NowPlaying"; 16 + import { followsAtom } from "../../atoms/follows"; 17 + import { IconCheck, IconPlus } from "@tabler/icons-react"; 18 + import { Button } from "baseui/button"; 19 + import SignInModal from "../SignInModal"; 20 + import { 21 + useFollowAccountMutation, 22 + useFollowersQuery, 23 + useUnfollowAccountMutation, 24 + } from "../../hooks/useGraph"; 16 25 17 26 export type HandleProps = { 18 27 link: string; ··· 20 29 }; 21 30 22 31 function Handle(props: HandleProps) { 32 + const [follows, setFollows] = useAtom(followsAtom); 33 + const [isSignInOpen, setIsSignInOpen] = useState(false); 23 34 const { link, did } = props; 24 35 const [profiles, setProfiles] = useAtom(profilesAtom); 25 36 const profile = useProfileByDidQuery(did); 26 37 const profileStats = useProfileStatsByDidQuery(did); 27 38 const [stats, setStats] = useAtom(statsAtom); 39 + const { mutate: followAccount } = useFollowAccountMutation(); 40 + const { mutate: unfollowAccount } = useUnfollowAccountMutation(); 41 + const currentDid = localStorage.getItem("did"); 42 + const { data, isLoading } = useFollowersQuery( 43 + profile.data?.did, 44 + 1, 45 + currentDid ? [currentDid] : undefined, 46 + ); 47 + 48 + const onFollow = () => { 49 + if (!localStorage.getItem("token")) { 50 + setIsSignInOpen(true); 51 + return; 52 + } 53 + setFollows((prev) => new Set(prev).add(profile.data?.did)); 54 + followAccount(profile.data?.did); 55 + }; 56 + 57 + const onUnfollow = () => { 58 + if (!localStorage.getItem("token")) { 59 + setIsSignInOpen(true); 60 + return; 61 + } 62 + setFollows((prev) => { 63 + const newSet = new Set(prev); 64 + newSet.delete(profile.data?.did); 65 + return newSet; 66 + }); 67 + unfollowAccount(profile.data?.did); 68 + }; 69 + 70 + useEffect(() => { 71 + if (!data || isLoading) { 72 + return; 73 + } 74 + setFollows((prev) => { 75 + const newSet = new Set(prev); 76 + if ( 77 + data.followers.some( 78 + (follower: { did: string }) => follower.did === currentDid, 79 + ) 80 + ) { 81 + newSet.add(profile.data?.did); 82 + } else { 83 + newSet.delete(profile.data?.did); 84 + } 85 + return newSet; 86 + }); 87 + }, [data, isLoading]); 28 88 29 89 useEffect(() => { 30 90 if (profile.isLoading || profile.isError) { ··· 73 133 }, [profileStats.data, profileStats.isLoading, profileStats.isError, did]); 74 134 75 135 return ( 76 - <StatefulPopover 77 - content={() => ( 78 - <Block className="!bg-[var(--color-background)] !text-[var(--color-text)] p-[15px] w-[380px] rounded-[6px] border-[1px] border-[var(--color-border)]"> 79 - <div className="flex flex-row items-center"> 80 - <Link to={link} className="no-underline"> 81 - <Avatar 82 - src={profiles[did]?.avatar} 83 - name={profiles[did]?.displayName} 84 - size={"60px"} 85 - /> 86 - </Link> 87 - <div className="ml-[16px]"> 88 - <Link to={link} className="no-underline"> 89 - <LabelMedium 90 - marginTop={"10px"} 91 - className="!text-[var(--color-text)]" 92 - > 93 - {profiles[did]?.displayName} 94 - </LabelMedium> 95 - </Link> 96 - <a 97 - href={`https://bsky.app/profile/${profiles[did]?.handle}`} 98 - className="no-underline text-[var(--color-primary)]" 99 - > 100 - <LabelSmall className="!text-[var(--color-primary)] mt-[3px] mb-[25px]"> 101 - @{did} 102 - </LabelSmall> 103 - </a> 136 + <> 137 + <StatefulPopover 138 + content={() => ( 139 + <Block className="!bg-[var(--color-background)] !text-[var(--color-text)] p-[15px] w-[380px] rounded-[6px] border-[1px] border-[var(--color-border)]"> 140 + <div className="flex flex-row items-start justify-between"> 141 + <div className="flex flex-row items-center"> 142 + <Link to={link} className="no-underline"> 143 + <Avatar 144 + src={profiles[did]?.avatar} 145 + name={profiles[did]?.displayName} 146 + size={"60px"} 147 + /> 148 + </Link> 149 + <div className="ml-[16px]"> 150 + <Link to={link} className="no-underline"> 151 + <LabelMedium 152 + marginTop={"10px"} 153 + className="!text-[var(--color-text)]" 154 + > 155 + {profiles[did]?.displayName} 156 + </LabelMedium> 157 + </Link> 158 + <a 159 + href={`https://bsky.app/profile/${profiles[did]?.handle}`} 160 + className="no-underline text-[var(--color-primary)]" 161 + target="_blank" 162 + > 163 + <LabelSmall className="!text-[var(--color-primary)] mt-[3px] mb-[25px]"> 164 + @{did} 165 + </LabelSmall> 166 + </a> 167 + </div> 168 + </div> 169 + {(profile.data?.did !== localStorage.getItem("did") || 170 + !localStorage.getItem("did")) && ( 171 + <div className="ml-auto mt-[10px]"> 172 + {!follows.has(profile.data?.did) && !isLoading && ( 173 + <Button 174 + shape="pill" 175 + size="mini" 176 + startEnhancer={<IconPlus size={16} />} 177 + onClick={onFollow} 178 + overrides={{ 179 + BaseButton: { 180 + style: { 181 + minWidth: "90px", 182 + backgroundColor: "#ff2876", 183 + ":hover": { 184 + backgroundColor: "#ff2876", 185 + }, 186 + ":focus": { 187 + backgroundColor: "#ff2876", 188 + }, 189 + }, 190 + }, 191 + }} 192 + > 193 + Follow 194 + </Button> 195 + )} 196 + {follows.has(profile.data?.did) && !isLoading && ( 197 + <Button 198 + shape="pill" 199 + size="mini" 200 + startEnhancer={<IconCheck size={16} />} 201 + onClick={onUnfollow} 202 + overrides={{ 203 + BaseButton: { 204 + style: { 205 + backgroundColor: "var(--color-default-button)", 206 + color: "var(--color-text)", 207 + ":hover": { 208 + backgroundColor: "var(--color-default-button)", 209 + }, 210 + ":focus": { 211 + backgroundColor: "var(--color-default-button)", 212 + }, 213 + }, 214 + }, 215 + }} 216 + > 217 + Following 218 + </Button> 219 + )} 220 + </div> 221 + )} 104 222 </div> 105 - </div> 106 223 107 - {stats[did] && <Stats stats={stats[did]} mb={1} />} 224 + {stats[did] && <Stats stats={stats[did]} mb={1} />} 108 225 109 - <NowPlaying did={did} /> 110 - </Block> 111 - )} 112 - triggerType={TRIGGER_TYPE.hover} 113 - autoFocus={false} 114 - focusLock={false} 115 - > 116 - <Link to={link} className="no-underline"> 117 - <LabelMedium className="!text-[var(--color-primary)] !overflow-hidden !text-ellipsis !max-w-[220px] !text-[14px]"> 118 - @{did} 119 - </LabelMedium> 120 - </Link> 121 - </StatefulPopover> 226 + <NowPlaying did={did} /> 227 + </Block> 228 + )} 229 + triggerType={TRIGGER_TYPE.hover} 230 + autoFocus={false} 231 + focusLock={false} 232 + > 233 + <Link to={link} className="no-underline"> 234 + <LabelMedium className="!text-[var(--color-primary)] !overflow-hidden !text-ellipsis !max-w-[220px] !text-[14px]"> 235 + @{did} 236 + </LabelMedium> 237 + </Link> 238 + </StatefulPopover> 239 + <SignInModal 240 + isOpen={isSignInOpen} 241 + onClose={() => setIsSignInOpen(false)} 242 + follow 243 + /> 244 + </> 122 245 ); 123 246 } 124 247
+5 -2
apps/web/src/components/SignInModal/SignInModal.tsx
··· 8 8 isOpen: boolean; 9 9 onClose: () => void; 10 10 like?: boolean; 11 + follow?: boolean; 11 12 } 12 13 13 14 function SignInModal(props: SignInModalProps) { 14 - const { isOpen, onClose, like } = props; 15 + const { isOpen, onClose, like, follow } = props; 15 16 const [handle, setHandle] = useState(""); 16 17 17 18 const onLogin = async () => { ··· 57 58 <h1 style={{ color: "#ff2876", textAlign: "center" }}>Rocksky</h1> 58 59 <p className="text-[var(--color-text)] text-[18px] mt-[40px] mb-[20px]"> 59 60 {!like 60 - ? "Sign in or create your account to join the conversation!" 61 + ? !follow 62 + ? "Sign in or create your account to join the conversation!" 63 + : "Sign in or create your account to follow users!" 61 64 : "Sign in or create your account to like songs!"} 62 65 </p> 63 66 <div style={{ marginBottom: 20 }}>
+48 -5
apps/web/src/hooks/useGraph.tsx
··· 1 - import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 1 + import { 2 + useInfiniteQuery, 3 + useMutation, 4 + useQuery, 5 + useQueryClient, 6 + } from "@tanstack/react-query"; 2 7 import { 3 8 getFollowers, 4 9 getFollows, ··· 9 14 export const useFollowsQuery = ( 10 15 actor: string, 11 16 limit: number, 17 + dids?: string[], 12 18 cursor?: string, 13 19 ) => 14 20 useQuery({ 15 - queryKey: ["follows", actor, limit, cursor], 16 - queryFn: () => getFollows(actor, limit, cursor), 21 + queryKey: ["follows", actor, limit, dids, cursor], 22 + queryFn: () => getFollows(actor, limit, dids, cursor), 23 + }); 24 + 25 + export const useFollowsInfiniteQuery = ( 26 + actor: string, 27 + limit: number, 28 + dids?: string[], 29 + ) => 30 + useInfiniteQuery({ 31 + queryKey: ["follows-infinite", actor, dids, limit], 32 + queryFn: ({ pageParam }) => getFollows(actor, limit, dids, pageParam), 33 + getNextPageParam: (lastPage) => lastPage.cursor, 34 + initialPageParam: undefined as string | undefined, 17 35 }); 18 36 19 37 export const useFollowersQuery = ( 20 38 actor: string, 21 39 limit: number, 40 + dids?: string[], 22 41 cursor?: string, 23 42 ) => 24 43 useQuery({ 25 - queryKey: ["followers", actor, limit, cursor], 26 - queryFn: () => getFollowers(actor, limit, cursor), 44 + queryKey: ["followers", actor, limit, dids, cursor], 45 + queryFn: () => getFollowers(actor, limit, dids, cursor), 46 + }); 47 + 48 + export const useFollowersInfiniteQuery = ( 49 + actor: string, 50 + limit: number, 51 + dids?: string[], 52 + ) => 53 + useInfiniteQuery({ 54 + queryKey: ["followers-infinite", actor, limit, dids], 55 + queryFn: ({ pageParam }) => getFollowers(actor, limit, dids, pageParam), 56 + getNextPageParam: (lastPage) => lastPage.cursor, 57 + initialPageParam: undefined as string | undefined, 27 58 }); 28 59 29 60 export const useFollowAccountMutation = () => { ··· 33 64 onSuccess: (_data, account) => { 34 65 queryClient.invalidateQueries({ queryKey: ["follows"] }); 35 66 queryClient.invalidateQueries({ queryKey: ["followers", account] }); 67 + queryClient.invalidateQueries({ 68 + queryKey: ["follows-infinite"], 69 + }); 70 + queryClient.invalidateQueries({ 71 + queryKey: ["followers-infinite"], 72 + }); 36 73 }, 37 74 }); 38 75 }; ··· 44 81 onSuccess: (_data, account) => { 45 82 queryClient.invalidateQueries({ queryKey: ["follows"] }); 46 83 queryClient.invalidateQueries({ queryKey: ["followers", account] }); 84 + queryClient.invalidateQueries({ 85 + queryKey: ["follows-infinite"], 86 + }); 87 + queryClient.invalidateQueries({ 88 + queryKey: ["followers-infinite"], 89 + }); 47 90 }, 48 91 }); 49 92 };
+1 -1
apps/web/src/index.css
··· 103 103 --color-placeholder: #ffffffb4; 104 104 --color-clear-input: #ffffff; 105 105 --color-menu-hover: #1f0d3c; 106 - --color-default-button: rgba(255, 255, 255, 0.05); 106 + --color-default-button: rgba(255, 255, 255, 0.06); 107 107 --color-purple: oklch(0.5478 0.201 296.07); 108 108 --color-blue: oklch(0.789 0.154 211.53); 109 109 --color-pink: oklch(0.6372 0.229 346.83);
+146 -34
apps/web/src/pages/profile/Profile.tsx
··· 18 18 import Overview from "./overview"; 19 19 import Playlists from "./playlists"; 20 20 import { Button } from "baseui/button"; 21 - import { IconPlus } from "@tabler/icons-react"; 21 + import { IconPlus, IconCheck } from "@tabler/icons-react"; 22 + import { followsAtom } from "../../atoms/follows"; 23 + import SignInModal from "../../components/SignInModal"; 24 + import { 25 + useFollowAccountMutation, 26 + useFollowersQuery, 27 + useUnfollowAccountMutation, 28 + } from "../../hooks/useGraph"; 29 + import Follows from "./follows"; 30 + import Followers from "./followers"; 31 + import { activeTabAtom } from "../../atoms/tab"; 22 32 23 33 const Group = styled.div` 24 34 display: flex; ··· 40 50 }; 41 51 42 52 function Profile(props: ProfileProps) { 53 + const [follows, setFollows] = useAtom(followsAtom); 54 + const [isSignInOpen, setIsSignInOpen] = useState(false); 43 55 const [profiles, setProfiles] = useAtom(profilesAtom); 44 - const [activeKey, setActiveKey] = useState<Key>( 45 - _.get(props, "activeKey", "0").split("/")[0], 46 - ); 56 + const [activeKey, setActiveKey] = useAtom(activeTabAtom); 47 57 const { did } = useParams({ strict: false }); 48 58 const profile = useProfileByDidQuery(did!); 49 59 const setUser = useSetAtom(userAtom); 50 60 const { tab } = useSearch({ strict: false }); 61 + const { mutate: followAccount } = useFollowAccountMutation(); 62 + const { mutate: unfollowAccount } = useUnfollowAccountMutation(); 63 + const currentDid = localStorage.getItem("did"); 64 + const { data, isLoading } = useFollowersQuery( 65 + profile.data?.did, 66 + 1, 67 + currentDid ? [currentDid] : undefined, 68 + ); 69 + 70 + const onFollow = () => { 71 + if (!localStorage.getItem("token")) { 72 + setIsSignInOpen(true); 73 + return; 74 + } 75 + setFollows((prev) => new Set(prev).add(profile.data?.did)); 76 + followAccount(profile.data?.did); 77 + }; 78 + 79 + const onUnfollow = () => { 80 + if (!localStorage.getItem("token")) { 81 + setIsSignInOpen(true); 82 + return; 83 + } 84 + setFollows((prev) => { 85 + const newSet = new Set(prev); 86 + newSet.delete(profile.data?.did); 87 + return newSet; 88 + }); 89 + unfollowAccount(profile.data?.did); 90 + }; 91 + 92 + useEffect(() => { 93 + if (!props.activeKey) { 94 + return; 95 + } 96 + setActiveKey(_.get(props, "activeKey", "0").split("/")[0]); 97 + }, [props.activeKey]); 98 + 99 + useEffect(() => { 100 + if (!data || isLoading) { 101 + return; 102 + } 103 + setFollows((prev) => { 104 + const newSet = new Set(prev); 105 + if ( 106 + data.followers.some( 107 + (follower: { did: string }) => follower.did === currentDid, 108 + ) 109 + ) { 110 + newSet.add(profile.data?.did); 111 + } else { 112 + newSet.delete(profile.data?.did); 113 + } 114 + return newSet; 115 + }); 116 + }, [data, isLoading]); 51 117 52 118 useEffect(() => { 53 119 if (tab === undefined) { ··· 96 162 return; 97 163 } 98 164 99 - console.log( 100 - ">>", 101 - profile.data?.did !== localStorage.getItem("did"), 102 - profile.data?.did, 103 - localStorage.getItem("did"), 104 - ); 105 - 106 165 return ( 107 166 <Main> 108 167 <div className="pb-[100px] pt-[75px]"> ··· 127 186 <a 128 187 href={`https://bsky.app/profile/${profiles[did]?.handle}`} 129 188 className="no-underline text-[var(--color-primary)]" 189 + target="_blank" 130 190 > 131 191 @{profiles[did]?.handle} 132 192 </a> ··· 150 210 </ProfileInfo> 151 211 {(profile.data?.did !== localStorage.getItem("did") || 152 212 !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", 213 + <> 214 + {!follows.has(profile.data?.did) && !isLoading && ( 215 + <Button 216 + shape="pill" 217 + size="compact" 218 + startEnhancer={<IconPlus size={18} />} 219 + onClick={onFollow} 220 + overrides={{ 221 + BaseButton: { 222 + style: { 223 + marginTop: "12px", 224 + minWidth: "120px", 225 + backgroundColor: "#ff2876", 226 + ":hover": { 227 + backgroundColor: "#ff2876", 228 + }, 229 + ":focus": { 230 + backgroundColor: "#ff2876", 231 + }, 232 + }, 164 233 }, 165 - ":focus": { 166 - backgroundColor: "#ff2876", 234 + }} 235 + > 236 + Follow 237 + </Button> 238 + )} 239 + {follows.has(profile.data?.did) && !isLoading && ( 240 + <Button 241 + shape="pill" 242 + size="compact" 243 + startEnhancer={<IconCheck size={18} />} 244 + onClick={onUnfollow} 245 + overrides={{ 246 + BaseButton: { 247 + style: { 248 + marginTop: "12px", 249 + minWidth: "120px", 250 + backgroundColor: "var(--color-default-button)", 251 + color: "var(--color-text)", 252 + ":hover": { 253 + backgroundColor: "var(--color-default-button)", 254 + }, 255 + ":focus": { 256 + backgroundColor: "var(--color-default-button)", 257 + }, 258 + }, 167 259 }, 168 - }, 169 - }, 170 - }} 171 - > 172 - Follow 173 - </Button> 260 + }} 261 + > 262 + Following 263 + </Button> 264 + )} 265 + </> 174 266 )} 175 267 </Group> 176 268 ··· 222 314 /> 223 315 </Tab> 224 316 <Tab 225 - title="Playlists" 317 + title="Followers" 318 + overrides={{ 319 + Tab: { 320 + style: { 321 + color: "var(--color-text)", 322 + backgroundColor: "var(--color-background) !important", 323 + }, 324 + }, 325 + }} 326 + > 327 + <Followers /> 328 + </Tab> 329 + <Tab 330 + title="Following" 226 331 overrides={{ 227 332 Tab: { 228 333 style: { ··· 232 337 }, 233 338 }} 234 339 > 235 - <Playlists /> 340 + <Follows /> 236 341 </Tab> 237 342 <Tab 238 343 title="Loved Tracks" ··· 248 353 <LovedTracks /> 249 354 </Tab> 250 355 <Tab 251 - title="Tags" 356 + title="Playlists" 252 357 overrides={{ 253 358 Tab: { 254 359 style: { ··· 257 362 }, 258 363 }, 259 364 }} 260 - ></Tab> 365 + > 366 + <Playlists /> 367 + </Tab> 261 368 </Tabs> 262 369 <Shout type="profile" /> 263 370 </div> 371 + <SignInModal 372 + isOpen={isSignInOpen} 373 + onClose={() => setIsSignInOpen(false)} 374 + follow 375 + /> 264 376 </Main> 265 377 ); 266 378 }
+227
apps/web/src/pages/profile/followers/Followers.tsx
··· 1 + import { HeadingSmall, LabelMedium, LabelSmall } from "baseui/typography"; 2 + import { 3 + useFollowAccountMutation, 4 + useFollowersInfiniteQuery, 5 + useFollowsQuery, 6 + useUnfollowAccountMutation, 7 + } from "../../../hooks/useGraph"; 8 + import { useProfileByDidQuery } from "../../../hooks/useProfile"; 9 + import { Link, useParams } from "@tanstack/react-router"; 10 + import { Avatar } from "baseui/avatar"; 11 + import { useAtom } from "jotai"; 12 + import { activeTabAtom } from "../../../atoms/tab"; 13 + import { followsAtom } from "../../../atoms/follows"; 14 + import { Button } from "baseui/button"; 15 + import { IconCheck, IconPlus } from "@tabler/icons-react"; 16 + import SignInModal from "../../../components/SignInModal"; 17 + import { useState, useEffect, useRef } from "react"; 18 + 19 + function Followers() { 20 + const [, setActiveKey] = useAtom(activeTabAtom); 21 + const [follows, setFollows] = useAtom(followsAtom); 22 + const [isSignInOpen, setIsSignInOpen] = useState(false); 23 + const { did } = useParams({ strict: false }); 24 + const profile = useProfileByDidQuery(did!); 25 + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = 26 + useFollowersInfiniteQuery(profile.data?.did!, 20); 27 + const { mutate: followAccount } = useFollowAccountMutation(); 28 + const { mutate: unfollowAccount } = useUnfollowAccountMutation(); 29 + 30 + const loadMoreRef = useRef<HTMLDivElement>(null); 31 + 32 + // Intersection Observer for infinite scroll 33 + useEffect(() => { 34 + if (!loadMoreRef.current || !hasNextPage || isFetchingNextPage) return; 35 + 36 + const observer = new IntersectionObserver( 37 + (entries) => { 38 + if (entries[0]?.isIntersecting) { 39 + fetchNextPage(); 40 + } 41 + }, 42 + { threshold: 0.1 }, 43 + ); 44 + 45 + observer.observe(loadMoreRef.current); 46 + 47 + return () => observer.disconnect(); 48 + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); 49 + 50 + const allFollowers = data?.pages.flatMap((page) => page.followers) ?? []; 51 + 52 + const { data: followsData } = useFollowsQuery( 53 + localStorage.getItem("did")!, 54 + allFollowers.length, 55 + allFollowers 56 + .map((follower) => follower.did) 57 + .filter((x) => x !== localStorage.getItem("did")), 58 + ); 59 + 60 + useEffect(() => { 61 + if (!followsData) return; 62 + setFollows((prev) => { 63 + const newSet = new Set(prev); 64 + followsData.follows.forEach((follow: { did: string }) => { 65 + newSet.add(follow.did); 66 + }); 67 + return newSet; 68 + }); 69 + }, [followsData, setFollows]); 70 + 71 + const onFollow = (followerDid: string) => { 72 + if (!localStorage.getItem("token")) { 73 + setIsSignInOpen(true); 74 + return; 75 + } 76 + setFollows((prev) => new Set(prev).add(followerDid)); 77 + followAccount(followerDid); 78 + }; 79 + 80 + const onUnfollow = (followerDid: string) => { 81 + if (!localStorage.getItem("token")) { 82 + setIsSignInOpen(true); 83 + return; 84 + } 85 + setFollows((prev) => { 86 + const newSet = new Set(prev); 87 + newSet.delete(followerDid); 88 + return newSet; 89 + }); 90 + unfollowAccount(followerDid); 91 + }; 92 + 93 + return ( 94 + <> 95 + <HeadingSmall className="!text-[var(--color-text)]"> 96 + Followers 97 + </HeadingSmall> 98 + 99 + {allFollowers.length === 0 && data && ( 100 + <div className="text-center py-8"> 101 + <LabelMedium className="!text-[var(--color-text)] opacity-60"> 102 + No followers yet 103 + </LabelMedium> 104 + </div> 105 + )} 106 + 107 + {allFollowers.length > 0 && ( 108 + <div> 109 + {allFollowers.map((follower: any) => ( 110 + <div 111 + key={follower.did} 112 + className="flex items-start justify-between gap-2" 113 + > 114 + <div className="flex items-center gap-2"> 115 + <Link 116 + to={`/profile/${follower.handle}` as any} 117 + className="no-underline" 118 + onClick={() => setActiveKey(0)} 119 + > 120 + <Avatar 121 + src={follower.avatar} 122 + name={follower.displayName} 123 + size={"60px"} 124 + /> 125 + </Link> 126 + <div className="ml-[16px]"> 127 + <Link 128 + to={`/profile/${follower.handle}` as any} 129 + className="no-underline" 130 + onClick={() => setActiveKey(0)} 131 + > 132 + <LabelMedium 133 + marginTop={"10px"} 134 + className="!text-[var(--color-text)]" 135 + > 136 + {follower.displayName} 137 + </LabelMedium> 138 + </Link> 139 + <a 140 + href={`https://bsky.app/profile/${follower.handle}`} 141 + className="no-underline text-[var(--color-primary)]" 142 + target="_blank" 143 + > 144 + <LabelSmall className="!text-[var(--color-primary)] mt-[3px] mb-[25px]"> 145 + @{follower.handle} 146 + </LabelSmall> 147 + </a> 148 + </div> 149 + </div> 150 + {(follower.did !== localStorage.getItem("did") || 151 + !localStorage.getItem("did")) && ( 152 + <div className="ml-auto mt-[10px]"> 153 + {!follows.has(follower.did) && ( 154 + <Button 155 + shape="pill" 156 + size="mini" 157 + startEnhancer={<IconPlus size={16} />} 158 + onClick={() => onFollow(follower.did)} 159 + overrides={{ 160 + BaseButton: { 161 + style: { 162 + minWidth: "90px", 163 + backgroundColor: "#ff2876", 164 + ":hover": { 165 + backgroundColor: "#ff2876", 166 + }, 167 + ":focus": { 168 + backgroundColor: "#ff2876", 169 + }, 170 + }, 171 + }, 172 + }} 173 + > 174 + Follow 175 + </Button> 176 + )} 177 + {follows.has(follower.did) && ( 178 + <Button 179 + shape="pill" 180 + size="mini" 181 + startEnhancer={<IconCheck size={16} />} 182 + onClick={() => onUnfollow(follower.did)} 183 + overrides={{ 184 + BaseButton: { 185 + style: { 186 + backgroundColor: "var(--color-default-button)", 187 + color: "var(--color-text)", 188 + ":hover": { 189 + backgroundColor: "var(--color-default-button)", 190 + }, 191 + ":focus": { 192 + backgroundColor: "var(--color-default-button)", 193 + }, 194 + }, 195 + }, 196 + }} 197 + > 198 + Following 199 + </Button> 200 + )} 201 + </div> 202 + )} 203 + </div> 204 + ))} 205 + 206 + {/* Infinite scroll trigger */} 207 + <div ref={loadMoreRef} className="h-[20px] w-full" /> 208 + 209 + {isFetchingNextPage && ( 210 + <div className="text-center py-4"> 211 + <LabelSmall className="!text-[var(--color-text)]"> 212 + Loading more... 213 + </LabelSmall> 214 + </div> 215 + )} 216 + </div> 217 + )} 218 + <SignInModal 219 + isOpen={isSignInOpen} 220 + onClose={() => setIsSignInOpen(false)} 221 + follow 222 + /> 223 + </> 224 + ); 225 + } 226 + 227 + export default Followers;
+3
apps/web/src/pages/profile/followers/index.tsx
··· 1 + import Followers from "./Followers"; 2 + 3 + export default Followers;
+228
apps/web/src/pages/profile/follows/Follows.tsx
··· 1 + import { HeadingSmall, LabelMedium, LabelSmall } from "baseui/typography"; 2 + import { useProfileByDidQuery } from "../../../hooks/useProfile"; 3 + import { Link, useParams } from "@tanstack/react-router"; 4 + import { 5 + useFollowAccountMutation, 6 + useFollowsInfiniteQuery, 7 + useFollowsQuery, 8 + useUnfollowAccountMutation, 9 + } from "../../../hooks/useGraph"; 10 + import { Avatar } from "baseui/avatar"; 11 + import { useAtom } from "jotai"; 12 + import { activeTabAtom } from "../../../atoms/tab"; 13 + import { followsAtom } from "../../../atoms/follows"; 14 + import { Button } from "baseui/button"; 15 + import { IconCheck, IconPlus } from "@tabler/icons-react"; 16 + import SignInModal from "../../../components/SignInModal"; 17 + import { useState, useEffect, useRef } from "react"; 18 + 19 + function Follows() { 20 + const [, setActiveKey] = useAtom(activeTabAtom); 21 + const [follows, setFollows] = useAtom(followsAtom); 22 + const [isSignInOpen, setIsSignInOpen] = useState(false); 23 + const { did } = useParams({ strict: false }); 24 + const profile = useProfileByDidQuery(did!); 25 + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = 26 + useFollowsInfiniteQuery(profile.data?.did!, 20); 27 + const { mutate: followAccount } = useFollowAccountMutation(); 28 + const { mutate: unfollowAccount } = useUnfollowAccountMutation(); 29 + 30 + const loadMoreRef = useRef<HTMLDivElement>(null); 31 + 32 + // Intersection Observer for infinite scroll 33 + useEffect(() => { 34 + if (!loadMoreRef.current || !hasNextPage || isFetchingNextPage) return; 35 + 36 + const observer = new IntersectionObserver( 37 + (entries) => { 38 + if (entries[0]?.isIntersecting) { 39 + fetchNextPage(); 40 + } 41 + }, 42 + { threshold: 0.1 }, 43 + ); 44 + 45 + observer.observe(loadMoreRef.current); 46 + 47 + return () => observer.disconnect(); 48 + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); 49 + 50 + // Flatten all pages into a single array 51 + const allFollows = data?.pages.flatMap((page) => page.follows) ?? []; 52 + 53 + const { data: followsData } = useFollowsQuery( 54 + localStorage.getItem("did")!, 55 + allFollows.length, 56 + allFollows 57 + .map((follow) => follow.did) 58 + .filter((x) => x !== localStorage.getItem("did")), 59 + ); 60 + 61 + useEffect(() => { 62 + if (!followsData) return; 63 + setFollows((prev) => { 64 + const newSet = new Set(prev); 65 + followsData.follows.forEach((follow: { did: string }) => { 66 + newSet.add(follow.did); 67 + }); 68 + return newSet; 69 + }); 70 + }, [followsData, setFollows]); 71 + 72 + const onFollow = (followDid: string) => { 73 + if (!localStorage.getItem("token")) { 74 + setIsSignInOpen(true); 75 + return; 76 + } 77 + setFollows((prev) => new Set(prev).add(followDid)); 78 + followAccount(followDid); 79 + }; 80 + 81 + const onUnfollow = (followDid: string) => { 82 + if (!localStorage.getItem("token")) { 83 + setIsSignInOpen(true); 84 + return; 85 + } 86 + setFollows((prev) => { 87 + const newSet = new Set(prev); 88 + newSet.delete(followDid); 89 + return newSet; 90 + }); 91 + unfollowAccount(followDid); 92 + }; 93 + 94 + return ( 95 + <> 96 + <HeadingSmall className="!text-[var(--color-text)]"> 97 + Following 98 + </HeadingSmall> 99 + 100 + {allFollows.length === 0 && data && ( 101 + <div className="text-center py-8"> 102 + <LabelMedium className="!text-[var(--color-text)] opacity-60"> 103 + Not following anyone yet 104 + </LabelMedium> 105 + </div> 106 + )} 107 + 108 + {allFollows.length > 0 && ( 109 + <div> 110 + {allFollows.map((follow: any) => ( 111 + <div 112 + key={follow.did} 113 + className="flex items-start justify-between gap-2" 114 + > 115 + <div className="flex items-center gap-2"> 116 + <Link 117 + to={`/profile/${follow.handle}` as any} 118 + className="no-underline" 119 + onClick={() => setActiveKey(0)} 120 + > 121 + <Avatar 122 + src={follow.avatar} 123 + name={follow.displayName} 124 + size={"60px"} 125 + /> 126 + </Link> 127 + <div className="ml-[16px]"> 128 + <Link 129 + to={`/profile/${follow.handle}` as any} 130 + className="no-underline" 131 + onClick={() => setActiveKey(0)} 132 + > 133 + <LabelMedium 134 + marginTop={"10px"} 135 + className="!text-[var(--color-text)]" 136 + > 137 + {follow.displayName} 138 + </LabelMedium> 139 + </Link> 140 + <a 141 + href={`https://bsky.app/profile/${follow.handle}`} 142 + className="no-underline text-[var(--color-primary)]" 143 + target="_blank" 144 + > 145 + <LabelSmall className="!text-[var(--color-primary)] mt-[3px] mb-[25px]"> 146 + @{follow.handle} 147 + </LabelSmall> 148 + </a> 149 + </div> 150 + </div> 151 + {(follow.did !== localStorage.getItem("did") || 152 + !localStorage.getItem("did")) && ( 153 + <div className="ml-auto mt-[10px]"> 154 + {!follows.has(follow.did) && ( 155 + <Button 156 + shape="pill" 157 + size="mini" 158 + startEnhancer={<IconPlus size={16} />} 159 + onClick={() => onFollow(follow.did)} 160 + overrides={{ 161 + BaseButton: { 162 + style: { 163 + minWidth: "90px", 164 + backgroundColor: "#ff2876", 165 + ":hover": { 166 + backgroundColor: "#ff2876", 167 + }, 168 + ":focus": { 169 + backgroundColor: "#ff2876", 170 + }, 171 + }, 172 + }, 173 + }} 174 + > 175 + Follow 176 + </Button> 177 + )} 178 + {follows.has(follow.did) && ( 179 + <Button 180 + shape="pill" 181 + size="mini" 182 + startEnhancer={<IconCheck size={16} />} 183 + onClick={() => onUnfollow(follow.did)} 184 + overrides={{ 185 + BaseButton: { 186 + style: { 187 + backgroundColor: "var(--color-default-button)", 188 + color: "var(--color-text)", 189 + ":hover": { 190 + backgroundColor: "var(--color-default-button)", 191 + }, 192 + ":focus": { 193 + backgroundColor: "var(--color-default-button)", 194 + }, 195 + }, 196 + }, 197 + }} 198 + > 199 + Following 200 + </Button> 201 + )} 202 + </div> 203 + )} 204 + </div> 205 + ))} 206 + 207 + {/* Infinite scroll trigger */} 208 + <div ref={loadMoreRef} className="h-[20px] w-full" /> 209 + 210 + {isFetchingNextPage && ( 211 + <div className="text-center py-4"> 212 + <LabelSmall className="!text-[var(--color-text)]"> 213 + Loading more... 214 + </LabelSmall> 215 + </div> 216 + )} 217 + </div> 218 + )} 219 + <SignInModal 220 + isOpen={isSignInOpen} 221 + onClose={() => setIsSignInOpen(false)} 222 + follow 223 + /> 224 + </> 225 + ); 226 + } 227 + 228 + export default Follows;
+3
apps/web/src/pages/profile/follows/index.tsx
··· 1 + import Follows from "./Follows"; 2 + 3 + export default Follows;