A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
40
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix ,music components

+300 -64
+26 -8
lib/components/CurrentlyPlaying.tsx
··· 22 22 loadingIndicator?: React.ReactNode; 23 23 /** Preferred color scheme for theming. */ 24 24 colorScheme?: "light" | "dark" | "system"; 25 - /** Auto-refresh music data and album art every 15 seconds. Defaults to true. */ 25 + /** Auto-refresh music data and album art. When true, refreshes every 15 seconds. Defaults to true. */ 26 26 autoRefresh?: boolean; 27 + /** Refresh interval in milliseconds. Defaults to 15000 (15 seconds). Only used when autoRefresh is true. */ 28 + refreshInterval?: number; 27 29 } 28 30 29 31 /** ··· 42 44 did: string; 43 45 /** Record key for the status. */ 44 46 rkey: string; 45 - /** Auto-refresh music data and album art every 15 seconds. */ 46 - autoRefresh?: boolean; 47 47 /** Label to display. */ 48 48 label?: string; 49 - /** Refresh interval in milliseconds. */ 50 - refreshInterval?: number; 51 49 /** Handle to display in not listening state */ 52 50 handle?: string; 53 51 }; ··· 56 54 export const CURRENTLY_PLAYING_COLLECTION = "fm.teal.alpha.actor.status"; 57 55 58 56 /** 57 + * Compares two teal.fm status records to determine if the track has changed. 58 + * Used to prevent unnecessary re-renders during auto-refresh when the same track is still playing. 59 + */ 60 + const compareTealRecords = ( 61 + prev: TealActorStatusRecord | undefined, 62 + next: TealActorStatusRecord | undefined 63 + ): boolean => { 64 + if (!prev || !next) return prev === next; 65 + 66 + const prevTrack = prev.item.trackName; 67 + const nextTrack = next.item.trackName; 68 + const prevArtist = prev.item.artists[0]?.artistName; 69 + const nextArtist = next.item.artists[0]?.artistName; 70 + 71 + return prevTrack === nextTrack && prevArtist === nextArtist; 72 + }; 73 + 74 + /** 59 75 * Displays the currently playing track from teal.fm with auto-refresh. 60 76 * 61 77 * @param did - DID whose currently playing status should be fetched. ··· 64 80 * @param fallback - Node rendered before the first load begins. 65 81 * @param loadingIndicator - Node rendered while the status is loading. 66 82 * @param colorScheme - Preferred color scheme for theming the renderer. 67 - * @param autoRefresh - When true (default), refreshes album art and streaming platform links every 15 seconds. 83 + * @param autoRefresh - When true (default), refreshes the record every 15 seconds (or custom interval). 84 + * @param refreshInterval - Custom refresh interval in milliseconds. Defaults to 15000 (15 seconds). 68 85 * @returns A JSX subtree representing the currently playing track with loading states handled. 69 86 */ 70 87 export const CurrentlyPlaying: React.FC<CurrentlyPlayingProps> = React.memo(({ ··· 76 93 loadingIndicator, 77 94 colorScheme, 78 95 autoRefresh = true, 96 + refreshInterval = 15000, 79 97 }) => { 80 98 // Resolve handle from DID 81 99 const { handle } = useDidResolution(did); ··· 92 110 colorScheme={colorScheme} 93 111 did={did} 94 112 rkey={rkey} 95 - autoRefresh={autoRefresh} 96 113 label="CURRENTLY PLAYING" 97 - refreshInterval={15000} 98 114 handle={handle} 99 115 /> 100 116 ); ··· 118 134 renderer={Wrapped} 119 135 fallback={fallback} 120 136 loadingIndicator={loadingIndicator} 137 + refreshInterval={autoRefresh ? refreshInterval : undefined} 138 + compareRecords={compareTealRecords} 121 139 /> 122 140 ); 123 141 });
+18 -9
lib/components/LastPlayed.tsx
··· 2 2 import { useLatestRecord } from "../hooks/useLatestRecord"; 3 3 import { useDidResolution } from "../hooks/useDidResolution"; 4 4 import { CurrentlyPlayingRenderer } from "../renderers/CurrentlyPlayingRenderer"; 5 - import type { TealFeedPlayRecord } from "../types/teal"; 5 + import type { TealFeedPlayRecord, TealActorStatusRecord } from "../types/teal"; 6 6 7 7 /** 8 8 * Props for rendering the last played track from teal.fm feed. ··· 29 29 */ 30 30 export type LastPlayedRendererInjectedProps = { 31 31 /** Loaded teal.fm feed play record value. */ 32 - record: TealFeedPlayRecord; 32 + record: TealActorStatusRecord; 33 33 /** Indicates whether the record is currently loading. */ 34 34 loading: boolean; 35 35 /** Fetch error, if any. */ ··· 40 40 did: string; 41 41 /** Record key for the play record. */ 42 42 rkey: string; 43 - /** Auto-refresh music data and album art. */ 44 - autoRefresh?: boolean; 45 - /** Refresh interval in milliseconds. */ 46 - refreshInterval?: number; 47 43 /** Handle to display in not listening state */ 48 44 handle?: string; 49 45 }; ··· 75 71 // Resolve handle from DID 76 72 const { handle } = useDidResolution(did); 77 73 74 + // Auto-refresh key for refetching teal.fm record 75 + const [refreshKey, setRefreshKey] = React.useState(0); 76 + 77 + // Auto-refresh interval 78 + React.useEffect(() => { 79 + if (!autoRefresh) return; 80 + 81 + const interval = setInterval(() => { 82 + setRefreshKey((prev) => prev + 1); 83 + }, refreshInterval); 84 + 85 + return () => clearInterval(interval); 86 + }, [autoRefresh, refreshInterval]); 87 + 78 88 const { record, rkey, loading, error, empty } = useLatestRecord<TealFeedPlayRecord>( 79 89 did, 80 - LAST_PLAYED_COLLECTION 90 + LAST_PLAYED_COLLECTION, 91 + refreshKey, 81 92 ); 82 93 83 94 // Normalize TealFeedPlayRecord to match TealActorStatusRecord structure ··· 145 156 colorScheme={colorScheme} 146 157 did={did} 147 158 rkey={rkey || "unknown"} 148 - autoRefresh={autoRefresh} 149 159 label="LAST PLAYED" 150 - refreshInterval={refreshInterval} 151 160 handle={handle} 152 161 /> 153 162 );
+68 -6
lib/core/AtProtoRecord.tsx
··· 1 - import React from "react"; 1 + import React, { useState, useEffect, useRef } from "react"; 2 2 import { useAtProtoRecord } from "../hooks/useAtProtoRecord"; 3 3 4 4 /** ··· 15 15 fallback?: React.ReactNode; 16 16 /** React node shown while the record is being fetched. */ 17 17 loadingIndicator?: React.ReactNode; 18 + /** Auto-refresh interval in milliseconds. When set, the record will be refetched at this interval. */ 19 + refreshInterval?: number; 20 + /** Comparison function to determine if a record has changed. Used to prevent unnecessary re-renders during auto-refresh. */ 21 + compareRecords?: (prev: T | undefined, next: T | undefined) => boolean; 18 22 } 19 23 20 24 /** ··· 61 65 * 62 66 * When no custom renderer is provided, displays the record as formatted JSON. 63 67 * 68 + * **Auto-refresh**: Set `refreshInterval` to automatically refetch the record at the specified interval. 69 + * The component intelligently avoids re-rendering if the record hasn't changed (using `compareRecords`). 70 + * 64 71 * @example 65 72 * ```tsx 66 73 * // Fetch mode - retrieves record from network ··· 81 88 * /> 82 89 * ``` 83 90 * 91 + * @example 92 + * ```tsx 93 + * // Auto-refresh mode - refetches every 15 seconds 94 + * <AtProtoRecord 95 + * did="did:plc:example" 96 + * collection="fm.teal.alpha.actor.status" 97 + * rkey="self" 98 + * refreshInterval={15000} 99 + * compareRecords={(prev, next) => JSON.stringify(prev) === JSON.stringify(next)} 100 + * renderer={MyCustomRenderer} 101 + * /> 102 + * ``` 103 + * 84 104 * @param props - Either fetch props (did/collection/rkey) or prefetch props (record). 85 105 * @returns A rendered AT Protocol record with loading/error states handled. 86 106 */ ··· 89 109 renderer: Renderer, 90 110 fallback = null, 91 111 loadingIndicator = "Loading…", 112 + refreshInterval, 113 + compareRecords, 92 114 } = props; 93 115 const hasProvidedRecord = "record" in props; 94 116 const providedRecord = hasProvidedRecord ? props.record : undefined; 95 117 118 + // Extract fetch props for logging 119 + const fetchDid = hasProvidedRecord ? undefined : (props as any).did; 120 + const fetchCollection = hasProvidedRecord ? undefined : (props as any).collection; 121 + const fetchRkey = hasProvidedRecord ? undefined : (props as any).rkey; 122 + 123 + // State for managing auto-refresh 124 + const [refreshKey, setRefreshKey] = useState(0); 125 + const [stableRecord, setStableRecord] = useState<T | undefined>(providedRecord); 126 + const previousRecordRef = useRef<T | undefined>(providedRecord); 127 + 128 + // Auto-refresh interval 129 + useEffect(() => { 130 + if (!refreshInterval || hasProvidedRecord) return; 131 + 132 + const interval = setInterval(() => { 133 + setRefreshKey((prev) => prev + 1); 134 + }, refreshInterval); 135 + 136 + return () => clearInterval(interval); 137 + }, [refreshInterval, hasProvidedRecord, fetchCollection, fetchDid]); 138 + 96 139 const { 97 140 record: fetchedRecord, 98 141 error, 99 142 loading, 100 143 } = useAtProtoRecord<T>({ 101 - did: hasProvidedRecord ? undefined : props.did, 102 - collection: hasProvidedRecord ? undefined : props.collection, 103 - rkey: hasProvidedRecord ? undefined : props.rkey, 144 + did: fetchDid, 145 + collection: fetchCollection, 146 + rkey: fetchRkey, 147 + bypassCache: !!refreshInterval && refreshKey > 0, // Bypass cache on auto-refresh (but not initial load) 148 + _refreshKey: refreshKey, // Force hook to re-run 104 149 }); 105 150 106 - const record = providedRecord ?? fetchedRecord; 107 - const isLoading = loading && !providedRecord; 151 + // Determine which record to use 152 + const currentRecord = providedRecord ?? fetchedRecord; 153 + 154 + // Handle record changes with optional comparison 155 + useEffect(() => { 156 + if (!currentRecord) return; 157 + 158 + const hasChanged = compareRecords 159 + ? !compareRecords(previousRecordRef.current, currentRecord) 160 + : previousRecordRef.current !== currentRecord; 161 + 162 + if (hasChanged) { 163 + setStableRecord(currentRecord); 164 + previousRecordRef.current = currentRecord; 165 + } 166 + }, [currentRecord, compareRecords]); 167 + 168 + const record = stableRecord; 169 + const isLoading = loading && !providedRecord && !stableRecord; 108 170 109 171 if (error && !record) return <>{fallback}</>; 110 172 if (!record) return <>{isLoading ? loadingIndicator : fallback}</>;
+70
lib/hooks/useAtProtoRecord.ts
··· 15 15 collection?: string; 16 16 /** Record key string uniquely identifying the record within the collection. */ 17 17 rkey?: string; 18 + /** Force bypass cache and refetch from network. Useful for auto-refresh scenarios. */ 19 + bypassCache?: boolean; 20 + /** Internal refresh trigger - changes to this value force a refetch. */ 21 + _refreshKey?: number; 18 22 } 19 23 20 24 /** ··· 42 46 * @param did - DID (or handle before resolution) that owns the record. 43 47 * @param collection - NSID collection from which to fetch the record. 44 48 * @param rkey - Record key identifying the record within the collection. 49 + * @param bypassCache - Force bypass cache and refetch from network. Useful for auto-refresh scenarios. 50 + * @param _refreshKey - Internal parameter used to trigger refetches. 45 51 * @returns {AtProtoRecordState<T>} Object containing the resolved record, any error, and a loading flag. 46 52 */ 47 53 export function useAtProtoRecord<T = unknown>({ 48 54 did: handleOrDid, 49 55 collection, 50 56 rkey, 57 + bypassCache = false, 58 + _refreshKey = 0, 51 59 }: AtProtoRecordKey): AtProtoRecordState<T> { 52 60 const { recordCache } = useAtProto(); 53 61 const isBlueskyCollection = collection?.startsWith("app.bsky."); ··· 133 141 134 142 assignState({ loading: true, error: undefined, record: undefined }); 135 143 144 + // Bypass cache if requested (for auto-refresh scenarios) 145 + if (bypassCache) { 146 + assignState({ loading: true, error: undefined }); 147 + 148 + // Skip cache and fetch directly 149 + const controller = new AbortController(); 150 + 151 + const fetchPromise = (async () => { 152 + try { 153 + const { rpc } = await createAtprotoClient({ 154 + service: endpoint, 155 + }); 156 + const res = await ( 157 + rpc as unknown as { 158 + get: ( 159 + nsid: string, 160 + opts: { 161 + params: { 162 + repo: string; 163 + collection: string; 164 + rkey: string; 165 + }; 166 + }, 167 + ) => Promise<{ ok: boolean; data: { value: T } }>; 168 + } 169 + ).get("com.atproto.repo.getRecord", { 170 + params: { repo: did, collection, rkey }, 171 + }); 172 + if (!res.ok) throw new Error("Failed to load record"); 173 + return (res.data as { value: T }).value; 174 + } catch (err) { 175 + // Provide helpful error for banned/unreachable Bluesky PDSes 176 + if (endpoint.includes('.bsky.network')) { 177 + throw new Error( 178 + `Record unavailable. The Bluesky PDS (${endpoint}) may be unreachable or the account may be banned.` 179 + ); 180 + } 181 + throw err; 182 + } 183 + })(); 184 + 185 + fetchPromise 186 + .then((record) => { 187 + if (!cancelled) { 188 + assignState({ record, loading: false }); 189 + } 190 + }) 191 + .catch((e) => { 192 + if (!cancelled) { 193 + const err = e instanceof Error ? e : new Error(String(e)); 194 + assignState({ error: err, loading: false }); 195 + } 196 + }); 197 + 198 + return () => { 199 + cancelled = true; 200 + controller.abort(); 201 + }; 202 + } 203 + 136 204 // Use recordCache.ensure for deduplication and caching 137 205 const { promise, release } = recordCache.ensure<T>( 138 206 did, ··· 215 283 didError, 216 284 endpointError, 217 285 recordCache, 286 + bypassCache, 287 + _refreshKey, 218 288 ]); 219 289 220 290 // Return Bluesky result for app.bsky.* collections
+26 -7
lib/hooks/useBlueskyAppview.ts
··· 236 236 }: UseBlueskyAppviewOptions): UseBlueskyAppviewResult<T> { 237 237 const { recordCache, blueskyAppviewService, resolver } = useAtProto(); 238 238 const effectiveAppviewService = appviewService ?? blueskyAppviewService; 239 + 240 + // Only use this hook for Bluesky collections (app.bsky.*) 241 + const isBlueskyCollection = collection?.startsWith("app.bsky."); 242 + 239 243 const { 240 244 did, 241 245 error: didError, ··· 261 265 262 266 // Early returns for missing inputs or resolution errors 263 267 if (!handleOrDid || !collection || !rkey) { 268 + if (!cancelled) dispatch({ type: "RESET" }); 269 + return () => { 270 + cancelled = true; 271 + if (releaseRef.current) { 272 + releaseRef.current(); 273 + releaseRef.current = undefined; 274 + } 275 + }; 276 + } 277 + 278 + // Return early if not a Bluesky collection - this hook should not be used for other lexicons 279 + if (!isBlueskyCollection) { 264 280 if (!cancelled) dispatch({ type: "RESET" }); 265 281 return () => { 266 282 cancelled = true; ··· 683 699 }; 684 700 }> { 685 701 const { rpc } = await createAtprotoClient({ service }); 702 + 703 + const params: Record<string, unknown> = { 704 + repo: did, 705 + collection, 706 + limit, 707 + cursor, 708 + reverse: false, 709 + }; 710 + 686 711 return await (rpc as unknown as { 687 712 get: ( 688 713 nsid: string, ··· 695 720 }; 696 721 }>; 697 722 }).get("com.atproto.repo.listRecords", { 698 - params: { 699 - repo: did, 700 - collection, 701 - limit, 702 - cursor, 703 - reverse: false, 704 - }, 723 + params, 705 724 }); 706 725 } 707 726
+5 -2
lib/hooks/useLatestRecord.ts
··· 21 21 22 22 /** 23 23 * Fetches the most recent record from a collection using `listRecords(limit=3)`. 24 - * 24 + * 25 25 * Note: Slingshot does not support listRecords, so this always queries the actor's PDS directly. 26 - * 26 + * 27 27 * Records with invalid timestamps (before 2023, when ATProto was created) are automatically 28 28 * skipped, and additional records are fetched to find a valid one. 29 29 * 30 30 * @param handleOrDid - Handle or DID that owns the collection. 31 31 * @param collection - NSID of the collection to query. 32 + * @param refreshKey - Optional key that when changed, triggers a refetch. Use for auto-refresh scenarios. 32 33 * @returns {LatestRecordState<T>} Object reporting the latest record value, derived rkey, loading status, emptiness, and any error. 33 34 */ 34 35 export function useLatestRecord<T = unknown>( 35 36 handleOrDid: string | undefined, 36 37 collection: string, 38 + refreshKey?: number, 37 39 ): LatestRecordState<T> { 38 40 const { 39 41 did, ··· 157 159 resolvingEndpoint, 158 160 didError, 159 161 endpointError, 162 + refreshKey, 160 163 ]); 161 164 162 165 return state;
+74 -32
lib/renderers/CurrentlyPlayingRenderer.tsx
··· 1 - import React, { useState, useEffect } from "react"; 1 + import React, { useState, useEffect, useRef } from "react"; 2 2 import type { TealActorStatusRecord } from "../types/teal"; 3 3 4 4 export interface CurrentlyPlayingRendererProps { ··· 8 8 did: string; 9 9 rkey: string; 10 10 colorScheme?: "light" | "dark" | "system"; 11 - autoRefresh?: boolean; 12 11 /** Label to display (e.g., "CURRENTLY PLAYING", "LAST PLAYED"). Defaults to "CURRENTLY PLAYING". */ 13 12 label?: string; 14 - /** Refresh interval in milliseconds. Defaults to 15000 (15 seconds). */ 15 - refreshInterval?: number; 16 13 /** Handle to display in not listening state */ 17 14 handle?: string; 18 15 } ··· 41 38 record, 42 39 error, 43 40 loading, 44 - autoRefresh = true, 45 41 label = "CURRENTLY PLAYING", 46 - refreshInterval = 15000, 47 42 handle, 48 43 }) => { 49 44 const [albumArt, setAlbumArt] = useState<string | undefined>(undefined); 50 45 const [artworkLoading, setArtworkLoading] = useState(true); 51 46 const [songlinkData, setSonglinkData] = useState<SonglinkResponse | undefined>(undefined); 52 47 const [showPlatformModal, setShowPlatformModal] = useState(false); 53 - const [refreshKey, setRefreshKey] = useState(0); 48 + const previousTrackIdentityRef = useRef<string>(""); 54 49 55 - // Auto-refresh interval 56 - useEffect(() => { 57 - if (!autoRefresh) return; 58 - 59 - const interval = setInterval(() => { 60 - // Reset loading state before refresh 61 - setArtworkLoading(true); 62 - setRefreshKey((prev) => prev + 1); 63 - }, refreshInterval); 64 - 65 - return () => clearInterval(interval); 66 - }, [autoRefresh, refreshInterval]); 50 + // Auto-refresh interval removed - handled by AtProtoRecord 67 51 68 52 useEffect(() => { 69 53 if (!record) return; ··· 77 61 return; 78 62 } 79 63 80 - // Reset loading state at start of fetch 81 - if (refreshKey > 0) { 64 + // Create a unique identity for this track 65 + const trackIdentity = `${trackName}::${artistName}`; 66 + 67 + // Check if the track has actually changed 68 + const trackHasChanged = trackIdentity !== previousTrackIdentityRef.current; 69 + 70 + // Update tracked identity 71 + previousTrackIdentityRef.current = trackIdentity; 72 + 73 + // Only reset loading state and clear data when track actually changes 74 + // This prevents the loading flicker when auto-refreshing the same track 75 + if (trackHasChanged) { 76 + console.log(`[teal.fm] 🎵 Track changed: "${trackName}" by ${artistName}`); 82 77 setArtworkLoading(true); 78 + setAlbumArt(undefined); 79 + setSonglinkData(undefined); 80 + } else { 81 + console.log(`[teal.fm] 🔄 Auto-refresh: same track still playing ("${trackName}" by ${artistName})`); 83 82 } 84 83 85 84 let cancelled = false; ··· 100 99 // Extract album art from Songlink data 101 100 const entityId = data.entityUniqueId; 102 101 const entity = data.entitiesByUniqueId?.[entityId]; 102 + 103 + // Debug: Log the entity structure to see what fields are available 104 + console.log(`[teal.fm] ISRC entity data:`, { entityId, entity }); 105 + 103 106 if (entity?.thumbnailUrl) { 104 107 console.log(`[teal.fm] ✓ Found album art via ISRC lookup`); 105 108 setAlbumArt(entity.thumbnailUrl); 106 109 } else { 107 - console.warn(`[teal.fm] ISRC lookup succeeded but no thumbnail found`); 110 + console.warn(`[teal.fm] ISRC lookup succeeded but no thumbnail found`, { 111 + entityId, 112 + entityKeys: entity ? Object.keys(entity) : 'no entity', 113 + entity 114 + }); 108 115 } 109 116 setArtworkLoading(false); 110 117 return; ··· 187 194 if (!albumArt) { 188 195 const entityId = data.entityUniqueId; 189 196 const entity = data.entitiesByUniqueId?.[entityId]; 197 + 198 + // Debug: Log the entity structure to see what fields are available 199 + console.log(`[teal.fm] Songlink originUrl entity data:`, { entityId, entity }); 200 + 190 201 if (entity?.thumbnailUrl) { 191 202 console.log(`[teal.fm] ✓ Found album art via Songlink originUrl lookup`); 192 203 setAlbumArt(entity.thumbnailUrl); 193 204 } else { 194 - console.warn(`[teal.fm] Songlink lookup succeeded but no thumbnail found`); 205 + console.warn(`[teal.fm] Songlink lookup succeeded but no thumbnail found`, { 206 + entityId, 207 + entityKeys: entity ? Object.keys(entity) : 'no entity', 208 + entity 209 + }); 195 210 } 196 211 } 197 212 } else { ··· 215 230 return () => { 216 231 cancelled = true; 217 232 }; 218 - }, [record, refreshKey]); // Add refreshKey to trigger refetch 233 + }, [record]); // Runs on record change 219 234 220 235 if (error) 221 236 return ( ··· 266 281 267 282 const artistNames = item.artists.map((a) => a.artistName).join(", "); 268 283 269 - const platformConfig: Record<string, { name: string; icon: string; color: string }> = { 270 - spotify: { name: "Spotify", icon: "♫", color: "#1DB954" }, 271 - appleMusic: { name: "Apple Music", icon: "🎵", color: "#FA243C" }, 272 - youtube: { name: "YouTube", icon: "▶", color: "#FF0000" }, 273 - youtubeMusic: { name: "YouTube Music", icon: "▶", color: "#FF0000" }, 274 - tidal: { name: "Tidal", icon: "🌊", color: "#00FFFF" }, 275 - bandcamp: { name: "Bandcamp", icon: "△", color: "#1DA0C3" }, 284 + const platformConfig: Record<string, { name: string; svg: string; color: string }> = { 285 + spotify: { 286 + name: "Spotify", 287 + svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path fill="#1ed760" d="M248 8C111.1 8 0 119.1 0 256s111.1 248 248 248 248-111.1 248-248S384.9 8 248 8Z"/><path d="M406.6 231.1c-5.2 0-8.4-1.3-12.9-3.9-71.2-42.5-198.5-52.7-280.9-29.7-3.6 1-8.1 2.6-12.9 2.6-13.2 0-23.3-10.3-23.3-23.6 0-13.6 8.4-21.3 17.4-23.9 35.2-10.3 74.6-15.2 117.5-15.2 73 0 149.5 15.2 205.4 47.8 7.8 4.5 12.9 10.7 12.9 22.6 0 13.6-11 23.3-23.2 23.3zm-31 76.2c-5.2 0-8.7-2.3-12.3-4.2-62.5-37-155.7-51.9-238.6-29.4-4.8 1.3-7.4 2.6-11.9 2.6-10.7 0-19.4-8.7-19.4-19.4s5.2-17.8 15.5-20.7c27.8-7.8 56.2-13.6 97.8-13.6 64.9 0 127.6 16.1 177 45.5 8.1 4.8 11.3 11 11.3 19.7-.1 10.8-8.5 19.5-19.4 19.5zm-26.9 65.6c-4.2 0-6.8-1.3-10.7-3.6-62.4-37.6-135-39.2-206.7-24.5-3.9 1-9 2.6-11.9 2.6-9.7 0-15.8-7.7-15.8-15.8 0-10.3 6.1-15.2 13.6-16.8 81.9-18.1 165.6-16.5 237 26.2 6.1 3.9 9.7 7.4 9.7 16.5s-7.1 15.4-15.2 15.4z"/></svg>', 288 + color: "#1DB954" 289 + }, 290 + appleMusic: { 291 + name: "Apple Music", 292 + svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 361 361"><defs><linearGradient id="apple-grad" x1="180" y1="358.6" x2="180" y2="7.76" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#FA233B"/><stop offset="1" style="stop-color:#FB5C74"/></linearGradient></defs><path fill="url(#apple-grad)" d="M360 112.61V247.39c0 4.3 0 8.6-.02 12.9-.02 3.62-.06 7.24-.16 10.86-.21 7.89-.68 15.84-2.08 23.64-1.42 7.92-3.75 15.29-7.41 22.49-3.6 7.07-8.3 13.53-13.91 19.14-5.61 5.61-12.08 10.31-19.15 13.91-7.19 3.66-14.56 5.98-22.47 7.41-7.8 1.4-15.76 1.87-23.65 2.08-3.62.1-7.24.14-10.86.16-4.3.03-8.6.02-12.9.02H112.61c-4.3 0-8.6 0-12.9-.02-3.62-.02-7.24-.06-10.86-.16-7.89-.21-15.85-.68-23.65-2.08-7.92-1.42-15.28-3.75-22.47-7.41-7.07-3.6-13.54-8.3-19.15-13.91-5.61-5.61-10.31-12.07-13.91-19.14-3.66-7.2-5.99-14.57-7.41-22.49-1.4-7.8-1.87-15.76-2.08-23.64-.1-3.62-.14-7.24-.16-10.86C0 255.99 0 251.69 0 247.39V112.61c0-4.3 0-8.6.02-12.9.02-3.62.06-7.24.16-10.86.21-7.89.68-15.84 2.08-23.64 1.42-7.92 3.75-15.29 7.41-22.49 3.6-7.07 8.3-13.53 13.91-19.14 5.61-5.61 12.08-10.31 19.15-13.91 7.19-3.66 14.56-5.98 22.47-7.41 7.8-1.4 15.76-1.87 23.65-2.08 3.62-.1 7.24-.14 10.86-.16C104.01 0 108.31 0 112.61 0h134.77c4.3 0 8.6 0 12.9.02 3.62.02 7.24.06 10.86.16 7.89.21 15.85.68 23.65 2.08 7.92 1.42 15.28 3.75 22.47 7.41 7.07 3.6 13.54 8.3 19.15 13.91 5.61 5.61 10.31 12.07 13.91 19.14 3.66 7.2 5.99 14.57 7.41 22.49 1.4 7.8 1.87 15.76 2.08 23.64.1 3.62.14 7.24.16 10.86.03 4.3.02 8.6.02 12.9z"/><path fill="#FFF" d="M254.5 55c-.87.08-8.6 1.45-9.53 1.64l-107 21.59-.04.01c-2.79.59-4.98 1.58-6.67 3-2.04 1.71-3.17 4.13-3.6 6.95-.09.6-.24 1.82-.24 3.62v133.92c0 3.13-.25 6.17-2.37 8.76-2.12 2.59-4.74 3.37-7.81 3.99-2.33.47-4.66.94-6.99 1.41-8.84 1.78-14.59 2.99-19.8 5.01-4.98 1.93-8.71 4.39-11.68 7.51-5.89 6.17-8.28 14.54-7.46 22.38.7 6.69 3.71 13.09 8.88 17.82 3.49 3.2 7.85 5.63 12.99 6.66 5.33 1.07 11.01.7 19.31-.98 4.42-.89 8.56-2.28 12.5-4.61 3.9-2.3 7.24-5.37 9.85-9.11 2.62-3.75 4.31-7.92 5.24-12.35.96-4.57 1.19-8.7 1.19-13.26V128.82c0-6.22 1.76-7.86 6.78-9.08l93.09-18.75c5.79-1.11 8.52.54 8.52 6.61v79.29c0 3.14-.03 6.32-2.17 8.92-2.12 2.59-4.74 3.37-7.81 3.99-2.33.47-4.66.94-6.99 1.41-8.84 1.78-14.59 2.99-19.8 5.01-4.98 1.93-8.71 4.39-11.68 7.51-5.89 6.17-8.49 14.54-7.67 22.38.7 6.69 3.92 13.09 9.09 17.82 3.49 3.2 7.85 5.56 12.99 6.6 5.33 1.07 11.01.69 19.31-.98 4.42-.89 8.56-2.22 12.5-4.55 3.9-2.3 7.24-5.37 9.85-9.11 2.62-3.75 4.31-7.92 5.24-12.35.96-4.57 1-8.7 1-13.26V64.46c0-6.16-3.25-9.96-9.04-9.46z"/></svg>', 293 + color: "#FA243C" 294 + }, 295 + youtube: { 296 + name: "YouTube", 297 + svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300"><g transform="scale(.75)"><path fill="red" d="M199.917 105.63s-84.292 0-105.448 5.497c-11.328 3.165-20.655 12.493-23.82 23.987-5.498 21.156-5.498 64.969-5.498 64.969s0 43.979 5.497 64.802c3.165 11.494 12.326 20.655 23.82 23.82 21.323 5.664 105.448 5.664 105.448 5.664s84.459 0 105.615-5.497c11.494-3.165 20.655-12.16 23.654-23.82 5.664-20.99 5.664-64.803 5.664-64.803s.166-43.98-5.664-65.135c-2.999-11.494-12.16-20.655-23.654-23.654-21.156-5.83-105.615-5.83-105.615-5.83zm-26.82 53.974 70.133 40.479-70.133 40.312v-80.79z"/><path fill="#fff" d="m173.097 159.604 70.133 40.479-70.133 40.312v-80.79z"/></g></svg>', 298 + color: "#FF0000" 299 + }, 300 + youtubeMusic: { 301 + name: "YouTube Music", 302 + svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 176 176"><circle fill="#FF0000" cx="88" cy="88" r="88"/><path fill="#FFF" d="M88 46c23.1 0 42 18.8 42 42s-18.8 42-42 42-42-18.8-42-42 18.8-42 42-42m0-4c-25.4 0-46 20.6-46 46s20.6 46 46 46 46-20.6 46-46-20.6-46-46-46z"/><path fill="#FFF" d="m72 111 39-24-39-22z"/></svg>', 303 + color: "#FF0000" 304 + }, 305 + tidal: { 306 + name: "Tidal", 307 + svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M256 0c141.385 0 256 114.615 256 256S397.385 512 256 512 0 397.385 0 256 114.615 0 256 0zm50.384 219.459-50.372 50.383 50.379 50.391-50.382 50.393-50.395-50.393 50.393-50.389-50.393-50.39 50.395-50.372 50.38 50.369 50.389-50.375 50.382 50.382-50.382 50.392-50.394-50.391zm-100.767-.001-50.392 50.392-50.385-50.392 50.385-50.382 50.392 50.382z"/></svg>', 308 + color: "#000000" 309 + }, 310 + bandcamp: { 311 + name: "Bandcamp", 312 + svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#1DA0C3" d="M0 156v200h172l84-200z"/></svg>', 313 + color: "#1DA0C3" 314 + }, 276 315 }; 277 316 278 317 const availablePlatforms = songlinkData ··· 420 459 onClick={() => setShowPlatformModal(false)} 421 460 data-teal-platform="true" 422 461 > 423 - <span style={styles.platformIcon}>{config.icon}</span> 462 + <span 463 + style={styles.platformIcon} 464 + dangerouslySetInnerHTML={{ __html: config.svg }} 465 + /> 424 466 <span style={styles.platformName}>{config.name}</span> 425 467 <svg 426 468 width="20"
+13
lib/utils/cache.ts
··· 290 290 export class RecordCache { 291 291 private store = new Map<string, RecordCacheEntry>(); 292 292 private inFlight = new Map<string, InFlightRecordEntry>(); 293 + // Collections that should not be cached (e.g., status records that change frequently) 294 + private noCacheCollections = new Set<string>([ 295 + "fm.teal.alpha.actor.status", 296 + "fm.teal.alpha.feed.play", 297 + ]); 293 298 294 299 private key(did: string, collection: string, rkey: string): string { 295 300 return `${did}::${collection}::${rkey}`; 296 301 } 297 302 303 + private shouldCache(collection: string): boolean { 304 + return !this.noCacheCollections.has(collection); 305 + } 306 + 298 307 get<T = unknown>( 299 308 did?: string, 300 309 collection?: string, 301 310 rkey?: string, 302 311 ): T | undefined { 303 312 if (!did || !collection || !rkey) return undefined; 313 + // Don't return cached data for non-cacheable collections 314 + if (!this.shouldCache(collection)) return undefined; 304 315 return this.store.get(this.key(did, collection, rkey))?.record as 305 316 | T 306 317 | undefined; ··· 312 323 rkey: string, 313 324 record: T, 314 325 ): void { 326 + // Don't cache records for non-cacheable collections 327 + if (!this.shouldCache(collection)) return; 315 328 this.store.set(this.key(did, collection, rkey), { 316 329 record, 317 330 timestamp: Date.now(),