Demo using Slices Network GraphQL Relay API to make a teal.fm client

add plays subscription

+393 -55
+8
deno.lock
··· 15 15 "npm:eslint-plugin-react-refresh@~0.4.22": "0.4.23_eslint@9.36.0", 16 16 "npm:eslint@^9.36.0": "9.36.0", 17 17 "npm:globals@^16.4.0": "16.4.0", 18 + "npm:graphql-ws@^6.0.6": "6.0.6_graphql@16.11.0", 18 19 "npm:graphql@^16.11.0": "16.11.0", 19 20 "npm:postcss@^8.5.6": "8.5.6", 20 21 "npm:react-dom@^19.1.1": "19.2.0_react@19.2.0", ··· 1324 1325 "graphemer@1.4.0": { 1325 1326 "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" 1326 1327 }, 1328 + "graphql-ws@6.0.6_graphql@16.11.0": { 1329 + "integrity": "sha512-zgfER9s+ftkGKUZgc0xbx8T7/HMO4AV5/YuYiFc+AtgcO5T0v8AxYYNQ+ltzuzDZgNkYJaFspm5MMYLjQzrkmw==", 1330 + "dependencies": [ 1331 + "graphql@16.11.0" 1332 + ] 1333 + }, 1327 1334 "graphql@15.3.0": { 1328 1335 "integrity": "sha512-GTCJtzJmkFLWRfFJuoo9RWWa/FfamUHgiFosxi/X1Ani4AVWbeyBenZTNX6dM+7WSbbFfTo/25eh0LLkwHMw2w==" 1329 1336 }, ··· 2012 2019 "npm:eslint-plugin-react-refresh@~0.4.22", 2013 2020 "npm:eslint@^9.36.0", 2014 2021 "npm:globals@^16.4.0", 2022 + "npm:graphql-ws@^6.0.6", 2015 2023 "npm:graphql@^16.11.0", 2016 2024 "npm:postcss@^8.5.6", 2017 2025 "npm:react-dom@^19.1.1",
+1
package.json
··· 12 12 "schema:prod": "npx get-graphql-schema 'https://api.slices.network/graphql?slice=at://did:plc:fpruhuo22xkm5o7ttr2ktxdo/network.slices.slice/3m257yljpbg2a' > schema.graphql" 13 13 }, 14 14 "dependencies": { 15 + "graphql-ws": "^6.0.6", 15 16 "react": "^19.1.1", 16 17 "react-dom": "^19.1.1", 17 18 "react-relay": "^20.1.1",
+89 -43
schema.graphql
··· 40 40 } 41 41 42 42 type AppBskyActorProfileAggregated { 43 - avatar: String 44 - banner: String 45 - createdAt: String 46 - description: String 47 - displayName: String 48 - joinedViaStarterPack: String 49 - labels: String 50 - pinnedPost: String 43 + avatar: JSON 44 + banner: JSON 45 + createdAt: JSON 46 + description: JSON 47 + displayName: JSON 48 + joinedViaStarterPack: JSON 49 + labels: JSON 50 + pinnedPost: JSON 51 51 count: Int! 52 52 } 53 53 ··· 81 81 } 82 82 83 83 type AppBskyEmbedExternalAggregated { 84 - external: String 84 + external: JSON 85 85 count: Int! 86 86 } 87 87 ··· 115 115 } 116 116 117 117 type AppBskyEmbedImagesAggregated { 118 - images: String 118 + images: JSON 119 119 count: Int! 120 120 } 121 121 ··· 149 149 } 150 150 151 151 type AppBskyEmbedRecordAggregated { 152 - record: String 152 + record: JSON 153 153 count: Int! 154 154 } 155 155 ··· 184 184 } 185 185 186 186 type AppBskyEmbedRecordWithMediaAggregated { 187 - media: String 188 - record: String 187 + media: JSON 188 + record: JSON 189 189 count: Int! 190 190 } 191 191 ··· 222 222 } 223 223 224 224 type AppBskyEmbedVideoAggregated { 225 - alt: String 226 - aspectRatio: String 227 - captions: String 228 - video: String 225 + alt: JSON 226 + aspectRatio: JSON 227 + captions: JSON 228 + video: JSON 229 229 count: Int! 230 230 } 231 231 ··· 262 262 } 263 263 264 264 type AppBskyFeedPostgateAggregated { 265 - createdAt: String 266 - detachedEmbeddingUris: String 267 - embeddingRules: String 268 - post: String 265 + createdAt: JSON 266 + detachedEmbeddingUris: JSON 267 + embeddingRules: JSON 268 + post: JSON 269 269 count: Int! 270 270 } 271 271 ··· 302 302 } 303 303 304 304 type AppBskyFeedThreadgateAggregated { 305 - allow: String 306 - createdAt: String 307 - hiddenReplies: String 308 - post: String 305 + allow: JSON 306 + createdAt: JSON 307 + hiddenReplies: JSON 308 + post: JSON 309 309 count: Int! 310 310 } 311 311 ··· 340 340 } 341 341 342 342 type AppBskyRichtextFacetAggregated { 343 - features: String 344 - index: String 343 + features: JSON 344 + index: JSON 345 345 count: Int! 346 346 } 347 347 ··· 385 385 } 386 386 387 387 type ComAtprotoRepoStrongRefAggregated { 388 - cid: String 389 - uri: String 388 + cid: JSON 389 + uri: JSON 390 390 count: Int! 391 391 } 392 392 ··· 433 433 } 434 434 435 435 type FmTealAlphaFeedPlayAggregated { 436 - artistMbIds: String 437 - artistNames: String 438 - artists: String 439 - duration: String 440 - isrc: String 441 - musicServiceBaseDomain: String 442 - originUrl: String 443 - playedTime: String 444 - recordingMbId: String 445 - releaseMbId: String 446 - releaseName: String 447 - submissionClientAgent: String 448 - trackMbId: String 449 - trackName: String 436 + artistMbIds: JSON 437 + artistNames: JSON 438 + artists: JSON 439 + duration: JSON 440 + isrc: JSON 441 + musicServiceBaseDomain: JSON 442 + originUrl: JSON 443 + playedTime: JSON 444 + recordingMbId: JSON 445 + releaseMbId: JSON 446 + releaseName: JSON 447 + submissionClientAgent: JSON 448 + trackMbId: JSON 449 + trackName: JSON 450 450 count: Int! 451 451 } 452 452 ··· 574 574 input SortField { 575 575 field: String! 576 576 direction: SortDirection! 577 + } 578 + 579 + type Subscription { 580 + """Subscribe to app.bsky.feed.postgate record creation events""" 581 + appBskyFeedPostgateCreated: AppBskyFeedPostgate! 582 + 583 + """Subscribe to app.bsky.feed.postgate record update events""" 584 + appBskyFeedPostgateUpdated: AppBskyFeedPostgate! 585 + 586 + """ 587 + Subscribe to app.bsky.feed.postgate record deletion events. Returns the URI of deleted records. 588 + """ 589 + appBskyFeedPostgateDeleted: String! 590 + 591 + """Subscribe to app.bsky.feed.threadgate record creation events""" 592 + appBskyFeedThreadgateCreated: AppBskyFeedThreadgate! 593 + 594 + """Subscribe to app.bsky.feed.threadgate record update events""" 595 + appBskyFeedThreadgateUpdated: AppBskyFeedThreadgate! 596 + 597 + """ 598 + Subscribe to app.bsky.feed.threadgate record deletion events. Returns the URI of deleted records. 599 + """ 600 + appBskyFeedThreadgateDeleted: String! 601 + 602 + """Subscribe to app.bsky.actor.profile record creation events""" 603 + appBskyActorProfileCreated: AppBskyActorProfile! 604 + 605 + """Subscribe to app.bsky.actor.profile record update events""" 606 + appBskyActorProfileUpdated: AppBskyActorProfile! 607 + 608 + """ 609 + Subscribe to app.bsky.actor.profile record deletion events. Returns the URI of deleted records. 610 + """ 611 + appBskyActorProfileDeleted: String! 612 + 613 + """Subscribe to fm.teal.alpha.feed.play record creation events""" 614 + fmTealAlphaFeedPlayCreated: FmTealAlphaFeedPlay! 615 + 616 + """Subscribe to fm.teal.alpha.feed.play record update events""" 617 + fmTealAlphaFeedPlayUpdated: FmTealAlphaFeedPlay! 618 + 619 + """ 620 + Subscribe to fm.teal.alpha.feed.play record deletion events. Returns the URI of deleted records. 621 + """ 622 + fmTealAlphaFeedPlayDeleted: String! 577 623 } 578 624 579 625 type SyncResult {
+63 -2
src/App.tsx
··· 1 - import { graphql, useLazyLoadQuery, usePaginationFragment } from "react-relay"; 1 + import { 2 + graphql, 3 + useLazyLoadQuery, 4 + usePaginationFragment, 5 + useSubscription, 6 + } from "react-relay"; 2 7 import { useEffect, useRef } from "react"; 3 8 import type { AppQuery } from "./__generated__/AppQuery.graphql"; 4 9 import type { App_plays$key } from "./__generated__/App_plays.graphql"; 10 + import type { AppSubscription } from "./__generated__/AppSubscription.graphql"; 5 11 import TrackItem from "./TrackItem"; 6 12 import Layout from "./Layout"; 13 + import { 14 + ConnectionHandler, 15 + type GraphQLSubscriptionConfig, 16 + } from "relay-runtime"; 7 17 8 18 export default function App() { 9 19 const queryData = useLazyLoadQuery<AppQuery>( ··· 46 56 47 57 const loadMoreRef = useRef<HTMLDivElement>(null); 48 58 59 + // Subscribe to new plays 60 + const subscriptionConfig: GraphQLSubscriptionConfig<AppSubscription> = { 61 + subscription: graphql` 62 + subscription AppSubscription { 63 + fmTealAlphaFeedPlayCreated { 64 + uri 65 + playedTime 66 + ...TrackItem_play 67 + } 68 + } 69 + `, 70 + variables: {}, 71 + updater: (store) => { 72 + const newPlay = store.getRootField("fmTealAlphaFeedPlayCreated"); 73 + if (!newPlay) return; 74 + 75 + const root = store.getRoot(); 76 + const connection = ConnectionHandler.getConnection( 77 + root, 78 + "App_fmTealAlphaFeedPlays", 79 + { sortBy: [{ field: "playedTime", direction: "desc" }] } 80 + ); 81 + 82 + if (!connection) return; 83 + 84 + const edge = ConnectionHandler.createEdge( 85 + store, 86 + connection, 87 + newPlay, 88 + "FmTealAlphaFeedPlayEdge" 89 + ); 90 + 91 + ConnectionHandler.insertEdgeBefore(connection, edge); 92 + 93 + // Update totalCount 94 + const totalCountRecord = root.getLinkedRecord("fmTealAlphaFeedPlays", { 95 + sortBy: [{ field: "playedTime", direction: "desc" }], 96 + }); 97 + if (totalCountRecord) { 98 + const currentCount = totalCountRecord.getValue("totalCount") as number; 99 + if (typeof currentCount === "number") { 100 + totalCountRecord.setValue(currentCount + 1, "totalCount"); 101 + } 102 + } 103 + }, 104 + }; 105 + 106 + useSubscription(subscriptionConfig); 107 + 49 108 useEffect(() => { 50 109 window.scrollTo(0, 0); 51 110 }, []); ··· 120 179 {hasNext && ( 121 180 <div ref={loadMoreRef} className="py-12 text-center"> 122 181 {isLoadingNext ? ( 123 - <p className="text-xs text-zinc-600 uppercase tracking-wider">Loading...</p> 182 + <p className="text-xs text-zinc-600 uppercase tracking-wider"> 183 + Loading... 184 + </p> 124 185 ) : ( 125 186 <p className="text-xs text-zinc-700 uppercase tracking-wider">·</p> 126 187 )}
+157
src/__generated__/AppSubscription.graphql.ts
··· 1 + /** 2 + * @generated SignedSource<<401c6c4a1920db251447fa96aca8768a>> 3 + * @lightSyntaxTransform 4 + * @nogrep 5 + */ 6 + 7 + /* tslint:disable */ 8 + /* eslint-disable */ 9 + // @ts-nocheck 10 + 11 + import { ConcreteRequest } from 'relay-runtime'; 12 + import { FragmentRefs } from "relay-runtime"; 13 + export type AppSubscription$variables = Record<PropertyKey, never>; 14 + export type AppSubscription$data = { 15 + readonly fmTealAlphaFeedPlayCreated: { 16 + readonly playedTime: string | null | undefined; 17 + readonly uri: string; 18 + readonly " $fragmentSpreads": FragmentRefs<"TrackItem_play">; 19 + }; 20 + }; 21 + export type AppSubscription = { 22 + response: AppSubscription$data; 23 + variables: AppSubscription$variables; 24 + }; 25 + 26 + const node: ConcreteRequest = (function(){ 27 + var v0 = { 28 + "alias": null, 29 + "args": null, 30 + "kind": "ScalarField", 31 + "name": "uri", 32 + "storageKey": null 33 + }, 34 + v1 = { 35 + "alias": null, 36 + "args": null, 37 + "kind": "ScalarField", 38 + "name": "playedTime", 39 + "storageKey": null 40 + }; 41 + return { 42 + "fragment": { 43 + "argumentDefinitions": [], 44 + "kind": "Fragment", 45 + "metadata": null, 46 + "name": "AppSubscription", 47 + "selections": [ 48 + { 49 + "alias": null, 50 + "args": null, 51 + "concreteType": "FmTealAlphaFeedPlay", 52 + "kind": "LinkedField", 53 + "name": "fmTealAlphaFeedPlayCreated", 54 + "plural": false, 55 + "selections": [ 56 + (v0/*: any*/), 57 + (v1/*: any*/), 58 + { 59 + "args": null, 60 + "kind": "FragmentSpread", 61 + "name": "TrackItem_play" 62 + } 63 + ], 64 + "storageKey": null 65 + } 66 + ], 67 + "type": "Subscription", 68 + "abstractKey": null 69 + }, 70 + "kind": "Request", 71 + "operation": { 72 + "argumentDefinitions": [], 73 + "kind": "Operation", 74 + "name": "AppSubscription", 75 + "selections": [ 76 + { 77 + "alias": null, 78 + "args": null, 79 + "concreteType": "FmTealAlphaFeedPlay", 80 + "kind": "LinkedField", 81 + "name": "fmTealAlphaFeedPlayCreated", 82 + "plural": false, 83 + "selections": [ 84 + (v0/*: any*/), 85 + (v1/*: any*/), 86 + { 87 + "alias": null, 88 + "args": null, 89 + "kind": "ScalarField", 90 + "name": "trackName", 91 + "storageKey": null 92 + }, 93 + { 94 + "alias": null, 95 + "args": null, 96 + "kind": "ScalarField", 97 + "name": "artists", 98 + "storageKey": null 99 + }, 100 + { 101 + "alias": null, 102 + "args": null, 103 + "kind": "ScalarField", 104 + "name": "releaseName", 105 + "storageKey": null 106 + }, 107 + { 108 + "alias": null, 109 + "args": null, 110 + "kind": "ScalarField", 111 + "name": "releaseMbId", 112 + "storageKey": null 113 + }, 114 + { 115 + "alias": null, 116 + "args": null, 117 + "kind": "ScalarField", 118 + "name": "actorHandle", 119 + "storageKey": null 120 + }, 121 + { 122 + "alias": null, 123 + "args": null, 124 + "concreteType": "AppBskyActorProfile", 125 + "kind": "LinkedField", 126 + "name": "appBskyActorProfile", 127 + "plural": false, 128 + "selections": [ 129 + { 130 + "alias": null, 131 + "args": null, 132 + "kind": "ScalarField", 133 + "name": "displayName", 134 + "storageKey": null 135 + } 136 + ], 137 + "storageKey": null 138 + } 139 + ], 140 + "storageKey": null 141 + } 142 + ] 143 + }, 144 + "params": { 145 + "cacheID": "c856872303e0f4904ea70ed5dc54cce2", 146 + "id": null, 147 + "metadata": {}, 148 + "name": "AppSubscription", 149 + "operationKind": "subscription", 150 + "text": "subscription AppSubscription {\n fmTealAlphaFeedPlayCreated {\n uri\n playedTime\n ...TrackItem_play\n }\n}\n\nfragment TrackItem_play on FmTealAlphaFeedPlay {\n trackName\n playedTime\n artists\n releaseName\n releaseMbId\n actorHandle\n appBskyActorProfile {\n displayName\n }\n}\n" 151 + } 152 + }; 153 + })(); 154 + 155 + (node as any).hash = "96fa73287dd5ff8b0c3e83b8f663f65e"; 156 + 157 + export default node;
+4 -4
src/__generated__/TopAlbumsQuery.graphql.ts
··· 1 1 /** 2 - * @generated SignedSource<<a492b6190b60e9be64d199702b76977a>> 2 + * @generated SignedSource<<e2bf0d16ddd996a8b44b47387dd220b3>> 3 3 * @lightSyntaxTransform 4 4 * @nogrep 5 5 */ ··· 12 12 export type TopAlbumsQuery$variables = Record<PropertyKey, never>; 13 13 export type TopAlbumsQuery$data = { 14 14 readonly fmTealAlphaFeedPlaysAggregated: ReadonlyArray<{ 15 - readonly artists: string | null | undefined; 15 + readonly artists: any | null | undefined; 16 16 readonly count: number; 17 - readonly releaseMbId: string | null | undefined; 18 - readonly releaseName: string | null | undefined; 17 + readonly releaseMbId: any | null | undefined; 18 + readonly releaseName: any | null | undefined; 19 19 }>; 20 20 }; 21 21 export type TopAlbumsQuery = {
+4 -4
src/__generated__/TopTracksQuery.graphql.ts
··· 1 1 /** 2 - * @generated SignedSource<<ed55197dadbf24b9cf975295d63a2436>> 2 + * @generated SignedSource<<58e8aa653524405ace1405d28bd8f19e>> 3 3 * @lightSyntaxTransform 4 4 * @nogrep 5 5 */ ··· 12 12 export type TopTracksQuery$variables = Record<PropertyKey, never>; 13 13 export type TopTracksQuery$data = { 14 14 readonly fmTealAlphaFeedPlaysAggregated: ReadonlyArray<{ 15 - readonly artists: string | null | undefined; 15 + readonly artists: any | null | undefined; 16 16 readonly count: number; 17 - readonly releaseMbId: string | null | undefined; 18 - readonly trackName: string | null | undefined; 17 + readonly releaseMbId: any | null | undefined; 18 + readonly trackName: any | null | undefined; 19 19 }>; 20 20 }; 21 21 export type TopTracksQuery = {
+67 -2
src/main.tsx
··· 8 8 import TopAlbums from "./TopAlbums.tsx"; 9 9 import LoadingFallback from "./LoadingFallback.tsx"; 10 10 import { RelayEnvironmentProvider } from "react-relay"; 11 - import { Environment, Network, type FetchFunction } from "relay-runtime"; 11 + import { 12 + Environment, 13 + Network, 14 + type FetchFunction, 15 + Observable, 16 + type SubscribeFunction, 17 + type GraphQLResponse, 18 + } from "relay-runtime"; 19 + import { createClient } from "graphql-ws"; 12 20 13 21 const HTTP_ENDPOINT = 14 22 "https://api.slices.network/graphql?slice=at://did:plc:fpruhuo22xkm5o7ttr2ktxdo/network.slices.slice/3m257yljpbg2a"; 23 + 24 + const WS_ENDPOINT = 25 + "wss://api.slices.network/graphql/ws?slice=at://did:plc:fpruhuo22xkm5o7ttr2ktxdo/network.slices.slice/3m257yljpbg2a"; 15 26 16 27 const fetchGraphQL: FetchFunction = async (request, variables) => { 17 28 const resp = await fetch(HTTP_ENDPOINT, { ··· 25 36 return await resp.json(); 26 37 }; 27 38 39 + const wsClient = createClient({ 40 + url: WS_ENDPOINT, 41 + retryAttempts: 5, 42 + shouldRetry: () => true, 43 + on: { 44 + connected: () => { 45 + console.log("WebSocket connected!"); 46 + }, 47 + error: (error) => { 48 + console.error("WebSocket error:", error); 49 + }, 50 + closed: (event) => { 51 + console.log("WebSocket closed:", event); 52 + }, 53 + }, 54 + }); 55 + 56 + const subscribe: SubscribeFunction = (operation, variables) => { 57 + return Observable.create((sink) => { 58 + if (!operation.text) { 59 + sink.error(new Error("Missing operation text")); 60 + return; 61 + } 62 + 63 + return wsClient.subscribe( 64 + { 65 + operationName: operation.name, 66 + query: operation.text, 67 + variables, 68 + }, 69 + { 70 + next: (data) => { 71 + if (data.data !== null && data.data !== undefined) { 72 + sink.next({ data: data.data } as GraphQLResponse); 73 + } 74 + }, 75 + error: (error) => { 76 + console.error("Subscription error:", error); 77 + if (error instanceof Error) { 78 + sink.error(error); 79 + } else if (error instanceof CloseEvent) { 80 + sink.error( 81 + new Error(`WebSocket closed: ${error.code} ${error.reason}`) 82 + ); 83 + } else { 84 + sink.error(new Error(JSON.stringify(error))); 85 + } 86 + }, 87 + complete: () => sink.complete(), 88 + } 89 + ); 90 + }); 91 + }; 92 + 28 93 const environment = new Environment({ 29 - network: Network.create(fetchGraphQL), 94 + network: Network.create(fetchGraphQL, subscribe), 30 95 }); 31 96 32 97 createRoot(document.getElementById("root")!).render(