Demo using Slices Network GraphQL Relay API to make a teal.fm client
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}