A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import { useEffect, useState } from "react"; 2import { useDidResolution } from "./useDidResolution"; 3import { usePdsEndpoint } from "./usePdsEndpoint"; 4import { callListRecords } from "./useBlueskyAppview"; 5 6/** 7 * Shape of the state returned by {@link useLatestRecord}. 8 */ 9export interface LatestRecordState<T = unknown> { 10 /** Latest record value if one exists. */ 11 record?: T; 12 /** Record key for the fetched record, when derivable. */ 13 rkey?: string; 14 /** Error encountered while fetching. */ 15 error?: Error; 16 /** Indicates whether a fetch is in progress. */ 17 loading: boolean; 18 /** `true` when the collection has zero records. */ 19 empty: boolean; 20} 21 22/** 23 * Fetches the most recent record from a collection using `listRecords(limit=3)`. 24 * 25 * Note: Slingshot does not support listRecords, so this always queries the actor's PDS directly. 26 * 27 * Records with invalid timestamps (before 2023, when ATProto was created) are automatically 28 * skipped, and additional records are fetched to find a valid one. 29 * 30 * @param handleOrDid - Handle or DID that owns the collection. 31 * @param collection - NSID of the collection to query. 32 * @returns {LatestRecordState<T>} Object reporting the latest record value, derived rkey, loading status, emptiness, and any error. 33 */ 34export function useLatestRecord<T = unknown>( 35 handleOrDid: string | undefined, 36 collection: string, 37): LatestRecordState<T> { 38 const { 39 did, 40 error: didError, 41 loading: resolvingDid, 42 } = useDidResolution(handleOrDid); 43 const { 44 endpoint, 45 error: endpointError, 46 loading: resolvingEndpoint, 47 } = usePdsEndpoint(did); 48 const [state, setState] = useState<LatestRecordState<T>>({ 49 loading: !!handleOrDid, 50 empty: false, 51 }); 52 53 useEffect(() => { 54 let cancelled = false; 55 56 const assign = (next: Partial<LatestRecordState<T>>) => { 57 if (cancelled) return; 58 setState((prev) => ({ ...prev, ...next })); 59 }; 60 61 if (!handleOrDid) { 62 assign({ 63 loading: false, 64 record: undefined, 65 rkey: undefined, 66 error: undefined, 67 empty: false, 68 }); 69 return () => { 70 cancelled = true; 71 }; 72 } 73 74 if (didError) { 75 assign({ loading: false, error: didError, empty: false }); 76 return () => { 77 cancelled = true; 78 }; 79 } 80 81 if (endpointError) { 82 assign({ loading: false, error: endpointError, empty: false }); 83 return () => { 84 cancelled = true; 85 }; 86 } 87 88 if (resolvingDid || resolvingEndpoint || !did || !endpoint) { 89 assign({ loading: true, error: undefined }); 90 return () => { 91 cancelled = true; 92 }; 93 } 94 95 assign({ loading: true, error: undefined, empty: false }); 96 97 (async () => { 98 try { 99 // Slingshot doesn't support listRecords, so we query PDS directly 100 const res = await callListRecords<T>( 101 endpoint, 102 did, 103 collection, 104 3, // Fetch 3 in case some have invalid timestamps 105 ); 106 107 if (!res.ok) { 108 throw new Error("Failed to list records from PDS"); 109 } 110 111 const list = res.data.records; 112 if (list.length === 0) { 113 assign({ 114 loading: false, 115 empty: true, 116 record: undefined, 117 rkey: undefined, 118 }); 119 return; 120 } 121 122 // Find the first valid record (skip records before 2023) 123 const validRecord = list.find((item) => isValidTimestamp(item.value)); 124 125 if (!validRecord) { 126 console.warn("No valid records found (all had timestamps before 2023)"); 127 assign({ 128 loading: false, 129 empty: true, 130 record: undefined, 131 rkey: undefined, 132 }); 133 return; 134 } 135 136 const derivedRkey = validRecord.rkey ?? extractRkey(validRecord.uri); 137 assign({ 138 record: validRecord.value, 139 rkey: derivedRkey, 140 loading: false, 141 empty: false, 142 }); 143 } catch (e) { 144 assign({ error: e as Error, loading: false, empty: false }); 145 } 146 })(); 147 148 return () => { 149 cancelled = true; 150 }; 151 }, [ 152 handleOrDid, 153 did, 154 endpoint, 155 collection, 156 resolvingDid, 157 resolvingEndpoint, 158 didError, 159 endpointError, 160 ]); 161 162 return state; 163} 164 165function extractRkey(uri: string): string | undefined { 166 if (!uri) return undefined; 167 const parts = uri.split("/"); 168 return parts[parts.length - 1]; 169} 170 171/** 172 * Validates that a record has a reasonable timestamp (not before 2023). 173 * ATProto was created in 2023, so any timestamp before that is invalid. 174 */ 175function isValidTimestamp(record: unknown): boolean { 176 if (typeof record !== "object" || record === null) return true; 177 178 const recordObj = record as { createdAt?: string; indexedAt?: string }; 179 const timestamp = recordObj.createdAt || recordObj.indexedAt; 180 181 if (!timestamp || typeof timestamp !== "string") return true; // No timestamp to validate 182 183 try { 184 const date = new Date(timestamp); 185 // ATProto was created in 2023, reject anything before that 186 return date.getFullYear() >= 2023; 187 } catch { 188 // If we can't parse the date, consider it valid to avoid false negatives 189 return true; 190 } 191}