A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
at main 6.0 kB view raw
1import React, { useState, useEffect, useRef } from "react"; 2import { useAtProtoRecord } from "../hooks/useAtProtoRecord"; 3 4/** 5 * Common rendering customization props for AT Protocol records. 6 */ 7interface AtProtoRecordRenderProps<T> { 8 /** Custom renderer component that receives the fetched record and loading state. */ 9 renderer?: React.ComponentType<{ 10 record: T; 11 loading: boolean; 12 error?: Error; 13 }>; 14 /** React node displayed when no record is available (after error or before load). */ 15 fallback?: React.ReactNode; 16 /** React node shown while the record is being fetched. */ 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; 22} 23 24/** 25 * Props for fetching an AT Protocol record from the network. 26 */ 27type AtProtoRecordFetchProps<T> = AtProtoRecordRenderProps<T> & { 28 /** Repository DID that owns the record. */ 29 did: string; 30 /** NSID collection containing the record. */ 31 collection: string; 32 /** Record key identifying the specific record. */ 33 rkey: string; 34 /** Must be undefined when fetching (discriminates the union type). */ 35 record?: undefined; 36}; 37 38/** 39 * Props for rendering a prefetched AT Protocol record. 40 */ 41type AtProtoRecordProvidedRecordProps<T> = AtProtoRecordRenderProps<T> & { 42 /** Prefetched record value to render (skips network fetch). */ 43 record: T; 44 /** Optional DID for context (not used for fetching). */ 45 did?: string; 46 /** Optional collection for context (not used for fetching). */ 47 collection?: string; 48 /** Optional rkey for context (not used for fetching). */ 49 rkey?: string; 50}; 51 52/** 53 * Union type for AT Protocol record props - supports both fetching and prefetched records. 54 */ 55export type AtProtoRecordProps<T = unknown> = 56 | AtProtoRecordFetchProps<T> 57 | AtProtoRecordProvidedRecordProps<T>; 58 59/** 60 * Core component for fetching and rendering AT Protocol records with customizable presentation. 61 * 62 * Supports two modes: 63 * 1. **Fetch mode**: Provide `did`, `collection`, and `rkey` to fetch the record from the network 64 * 2. **Prefetch mode**: Provide a `record` directly to skip fetching (useful for SSR/caching) 65 * 66 * When no custom renderer is provided, displays the record as formatted JSON. 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 * 71 * @example 72 * ```tsx 73 * // Fetch mode - retrieves record from network 74 * <AtProtoRecord 75 * did="did:plc:example" 76 * collection="app.bsky.feed.post" 77 * rkey="3k2aexample" 78 * renderer={MyCustomRenderer} 79 * /> 80 * ``` 81 * 82 * @example 83 * ```tsx 84 * // Prefetch mode - uses provided record 85 * <AtProtoRecord 86 * record={myPrefetchedRecord} 87 * renderer={MyCustomRenderer} 88 * /> 89 * ``` 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 * 104 * @param props - Either fetch props (did/collection/rkey) or prefetch props (record). 105 * @returns A rendered AT Protocol record with loading/error states handled. 106 */ 107export function AtProtoRecord<T = unknown>(props: AtProtoRecordProps<T>) { 108 const { 109 renderer: Renderer, 110 fallback = null, 111 loadingIndicator = "Loading…", 112 refreshInterval, 113 compareRecords, 114 } = props; 115 const hasProvidedRecord = "record" in props; 116 const providedRecord = hasProvidedRecord ? props.record : undefined; 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 139 const { 140 record: fetchedRecord, 141 error, 142 loading, 143 } = useAtProtoRecord<T>({ 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 149 }); 150 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; 170 171 if (error && !record) return <>{fallback}</>; 172 if (!record) return <>{isLoading ? loadingIndicator : fallback}</>; 173 if (Renderer) 174 return <Renderer record={record} loading={isLoading} error={error} />; 175 return ( 176 <pre 177 style={{ 178 fontSize: 12, 179 padding: 8, 180 background: "#f5f5f5", 181 overflow: "auto", 182 }} 183 > 184 {JSON.stringify(record, null, 2)} 185 </pre> 186 ); 187}