forked from
chadtmiller.com/slices-teal-relay
Teal.fm frontend powered by slices.network
tealfm-slices.wisp.place
tealfm
slices
1import {
2 graphql,
3 useLazyLoadQuery,
4 usePaginationFragment,
5 useSubscription,
6} from "react-relay";
7import { useEffect, useRef, useMemo } 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 fmTealAlphaFeedPlays(
57 first: $count
58 after: $cursor
59 sortBy: [{ field: playedTime, direction: desc }]
60 ) @connection(key: "App_fmTealAlphaFeedPlays", 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
76 // Subscribe to new plays
77 const subscriptionConfig: GraphQLSubscriptionConfig<AppSubscription> = {
78 subscription: graphql`
79 subscription AppSubscription {
80 fmTealAlphaFeedPlayCreated {
81 uri
82 playedTime
83 ...TrackItem_play
84 }
85 }
86 `,
87 variables: {},
88 updater: (store) => {
89 const newPlay = store.getRootField("fmTealAlphaFeedPlayCreated");
90 if (!newPlay) return;
91
92 // Only add plays from the last 24 hours
93 const playedTime = newPlay.getValue("playedTime") as string | null;
94 if (!playedTime) return;
95
96 const playDate = new Date(playedTime);
97 const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
98
99 if (playDate < cutoff) {
100 // Play is too old, don't add it to the feed
101 return;
102 }
103
104 const root = store.getRoot();
105 const connection = ConnectionHandler.getConnection(
106 root,
107 "App_fmTealAlphaFeedPlays",
108 { sortBy: [{ field: "playedTime", direction: "desc" }] }
109 );
110
111 if (!connection) return;
112
113 const edge = ConnectionHandler.createEdge(
114 store,
115 connection,
116 newPlay,
117 "FmTealAlphaFeedPlayEdge"
118 );
119
120 ConnectionHandler.insertEdgeBefore(connection, edge);
121
122 // Update totalCount
123 const totalCountRecord = root.getLinkedRecord("fmTealAlphaFeedPlays", {
124 sortBy: [{ field: "playedTime", direction: "desc" }],
125 });
126 if (totalCountRecord) {
127 const currentCount = totalCountRecord.getValue("totalCount") as number;
128 if (typeof currentCount === "number") {
129 totalCountRecord.setValue(currentCount + 1, "totalCount");
130 }
131 }
132 },
133 };
134
135 useSubscription(subscriptionConfig);
136
137 useEffect(() => {
138 window.scrollTo(0, 0);
139 }, []);
140
141 const plays =
142 data?.fmTealAlphaFeedPlays?.edges
143 ?.map((edge) => edge.node)
144 .filter((n) => n != null) || [];
145
146 useEffect(() => {
147 if (!loadMoreRef.current || !hasNext) return;
148
149 const observer = new IntersectionObserver(
150 (entries) => {
151 if (entries[0].isIntersecting && hasNext && !isLoadingNext) {
152 loadNext(20);
153 }
154 },
155 { threshold: 0.1 }
156 );
157
158 observer.observe(loadMoreRef.current);
159
160 return () => observer.disconnect();
161 }, [hasNext, isLoadingNext, loadNext]);
162
163 // Group plays by date
164 const groupedPlays: { date: string; plays: typeof plays }[] = [];
165 let currentDate = "";
166
167 plays.forEach((play) => {
168 if (!play?.playedTime) return;
169
170 const playDate = new Date(play.playedTime).toLocaleDateString("en-US", {
171 weekday: "long",
172 day: "numeric",
173 month: "long",
174 year: "numeric",
175 });
176
177 if (playDate !== currentDate) {
178 currentDate = playDate;
179 groupedPlays.push({ date: playDate, plays: [play] });
180 } else {
181 groupedPlays[groupedPlays.length - 1].plays.push(play);
182 }
183 });
184
185 return (
186 <Layout headerChart={<ScrobbleChart queryRef={queryData} />}>
187 <div className="mb-8">
188 <p className="text-xs text-zinc-500 uppercase tracking-wider">
189 {data?.fmTealAlphaFeedPlays?.totalCount?.toLocaleString()} scrobbles
190 </p>
191 </div>
192
193 <div>
194 {groupedPlays.map((group, groupIndex) => (
195 <div key={groupIndex} className="mb-12">
196 <h2 className="text-xs text-zinc-600 font-medium mb-6 uppercase tracking-wider">
197 {group.date}
198 </h2>
199 <div className="space-y-1">
200 {group.plays.map((play, index) => (
201 <TrackItem key={index} play={play} />
202 ))}
203 </div>
204 </div>
205 ))}
206 </div>
207
208 {hasNext && (
209 <div ref={loadMoreRef} className="py-12 text-center">
210 {isLoadingNext ? (
211 <p className="text-xs text-zinc-600 uppercase tracking-wider">
212 Loading...
213 </p>
214 ) : (
215 <p className="text-xs text-zinc-700 uppercase tracking-wider">·</p>
216 )}
217 </div>
218 )}
219 </Layout>
220 );
221}