Demo using Slices Network GraphQL Relay API to make a teal.fm client
at main 6.5 kB view raw
1import { 2 graphql, 3 useLazyLoadQuery, 4 usePaginationFragment, 5 useSubscription, 6} from "react-relay"; 7import { useEffect, useMemo, useRef } from "react"; 8import type { AppQuery } from "./__generated__/AppQuery.graphql"; 9import type { App_plays$key } from "./__generated__/App_plays.graphql"; 10import type { AppSubscription } from "./__generated__/AppSubscription.graphql"; 11import TrackItem from "./TrackItem"; 12import Layout from "./Layout"; 13import ScrobbleChart from "./ScrobbleChart"; 14import { 15 ConnectionHandler, 16 type GraphQLSubscriptionConfig, 17} from "relay-runtime"; 18 19export default function App() { 20 const queryVariables = useMemo(() => { 21 // Round to start of day to keep timestamp stable 22 const now = new Date(); 23 now.setHours(0, 0, 0, 0); 24 const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000); 25 26 return { 27 chartWhere: { 28 playedTime: { 29 gte: ninetyDaysAgo.toISOString(), 30 }, 31 }, 32 }; 33 }, []); 34 35 const queryData = useLazyLoadQuery<AppQuery>( 36 graphql` 37 query AppQuery($chartWhere: FmTealAlphaFeedPlayWhereInput!) { 38 ...App_plays 39 ...ScrobbleChart_data 40 } 41 `, 42 queryVariables, 43 ); 44 45 const { data, loadNext, hasNext, isLoadingNext } = usePaginationFragment< 46 AppQuery, 47 App_plays$key 48 >( 49 graphql` 50 fragment App_plays on Query 51 @refetchable(queryName: "AppPaginationQuery") 52 @argumentDefinitions( 53 cursor: { type: "String" } 54 count: { type: "Int", defaultValue: 20 } 55 ) { 56 fmTealAlphaFeedPlay( 57 first: $count 58 after: $cursor 59 sortBy: [{ field: playedTime, direction: DESC }] 60 ) @connection(key: "App_fmTealAlphaFeedPlay", filters: ["sortBy"]) { 61 totalCount 62 edges { 63 node { 64 playedTime 65 ...TrackItem_play 66 } 67 } 68 } 69 } 70 `, 71 queryData, 72 ); 73 74 const loadMoreRef = useRef<HTMLDivElement>(null); 75 const loadNextRef = useRef(loadNext); 76 const isLoadingRef = useRef(false); 77 loadNextRef.current = loadNext; 78 79 // Subscribe to new plays 80 const subscriptionConfig: GraphQLSubscriptionConfig<AppSubscription> = 81 useMemo(() => ({ 82 subscription: graphql` 83 subscription AppSubscription { 84 fmTealAlphaFeedPlayCreated { 85 uri 86 playedTime 87 ...TrackItem_play 88 } 89 } 90 `, 91 variables: {}, 92 updater: (store) => { 93 const newPlay = store.getRootField("fmTealAlphaFeedPlayCreated"); 94 if (!newPlay) return; 95 96 // Only add plays from the last 24 hours 97 const playedTime = newPlay.getValue("playedTime") as string | null; 98 if (!playedTime) return; 99 100 const playDate = new Date(playedTime); 101 const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000); 102 103 if (playDate < cutoff) { 104 // Play is too old, don't add it to the feed 105 return; 106 } 107 108 const root = store.getRoot(); 109 const connection = ConnectionHandler.getConnection( 110 root, 111 "App_fmTealAlphaFeedPlay", 112 { sortBy: [{ field: "playedTime", direction: "DESC" }] }, 113 ); 114 115 if (!connection) return; 116 117 const edge = ConnectionHandler.createEdge( 118 store, 119 connection, 120 newPlay, 121 "FmTealAlphaFeedPlayEdge", 122 ); 123 124 ConnectionHandler.insertEdgeBefore(connection, edge); 125 126 // Update totalCount 127 const totalCountRecord = root.getLinkedRecord("fmTealAlphaFeedPlay", { 128 sortBy: [{ field: "playedTime", direction: "DESC" }], 129 }); 130 if (totalCountRecord) { 131 const currentCount = totalCountRecord.getValue( 132 "totalCount", 133 ) as number; 134 if (typeof currentCount === "number") { 135 totalCountRecord.setValue(currentCount + 1, "totalCount"); 136 } 137 } 138 }, 139 }), []); 140 141 useSubscription(subscriptionConfig); 142 143 useEffect(() => { 144 window.scrollTo(0, 0); 145 }, []); 146 147 const plays = data?.fmTealAlphaFeedPlay?.edges 148 ?.map((edge) => edge.node) 149 .filter((n) => n != null) || []; 150 151 // Sync the loading ref with isLoadingNext 152 useEffect(() => { 153 isLoadingRef.current = isLoadingNext; 154 }, [isLoadingNext]); 155 156 useEffect(() => { 157 if (!loadMoreRef.current || !hasNext) return; 158 159 const element = loadMoreRef.current; 160 const observer = new IntersectionObserver( 161 (entries) => { 162 if (entries[0].isIntersecting && !isLoadingRef.current) { 163 isLoadingRef.current = true; 164 loadNextRef.current(20); 165 } 166 }, 167 { threshold: 0.1 }, 168 ); 169 170 observer.observe(element); 171 172 return () => observer.disconnect(); 173 }, [hasNext]); 174 175 // Group plays by date 176 const groupedPlays: { date: string; plays: typeof plays }[] = []; 177 let currentDate = ""; 178 179 plays.forEach((play) => { 180 if (!play?.playedTime) return; 181 182 const playDate = new Date(play.playedTime).toLocaleDateString("en-US", { 183 weekday: "long", 184 day: "numeric", 185 month: "long", 186 year: "numeric", 187 }); 188 189 if (playDate !== currentDate) { 190 currentDate = playDate; 191 groupedPlays.push({ date: playDate, plays: [play] }); 192 } else { 193 groupedPlays[groupedPlays.length - 1].plays.push(play); 194 } 195 }); 196 197 return ( 198 <Layout headerChart={<ScrobbleChart queryRef={queryData} />}> 199 <div className="mb-8"> 200 <p className="text-xs text-zinc-500 uppercase tracking-wider"> 201 {data?.fmTealAlphaFeedPlay?.totalCount?.toLocaleString()} scrobbles 202 </p> 203 </div> 204 205 <div> 206 {groupedPlays.map((group, groupIndex) => ( 207 <div key={groupIndex} className="mb-12"> 208 <h2 className="text-xs text-zinc-600 font-medium mb-6 uppercase tracking-wider"> 209 {group.date} 210 </h2> 211 <div className="space-y-1"> 212 {group.plays.map((play, index) => ( 213 <TrackItem key={index} play={play} /> 214 ))} 215 </div> 216 </div> 217 ))} 218 </div> 219 220 {hasNext && ( 221 <div ref={loadMoreRef} className="py-12 text-center"> 222 {isLoadingNext 223 ? ( 224 <p className="text-xs text-zinc-600 uppercase tracking-wider"> 225 Loading... 226 </p> 227 ) 228 : ( 229 <p className="text-xs text-zinc-700 uppercase tracking-wider"> 230 · 231 </p> 232 )} 233 </div> 234 )} 235 </Layout> 236 ); 237}