A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
at main 5.0 kB view raw
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 * @param refreshKey - Optional key that when changed, triggers a refetch. Use for auto-refresh scenarios. 33 * @returns {LatestRecordState<T>} Object reporting the latest record value, derived rkey, loading status, emptiness, and any error. 34 */ 35export function useLatestRecord<T = unknown>( 36 handleOrDid: string | undefined, 37 collection: string, 38 refreshKey?: number, 39): LatestRecordState<T> { 40 const { 41 did, 42 error: didError, 43 loading: resolvingDid, 44 } = useDidResolution(handleOrDid); 45 const { 46 endpoint, 47 error: endpointError, 48 loading: resolvingEndpoint, 49 } = usePdsEndpoint(did); 50 const [state, setState] = useState<LatestRecordState<T>>({ 51 loading: !!handleOrDid, 52 empty: false, 53 }); 54 55 useEffect(() => { 56 let cancelled = false; 57 58 const assign = (next: Partial<LatestRecordState<T>>) => { 59 if (cancelled) return; 60 setState((prev) => ({ ...prev, ...next })); 61 }; 62 63 if (!handleOrDid) { 64 assign({ 65 loading: false, 66 record: undefined, 67 rkey: undefined, 68 error: undefined, 69 empty: false, 70 }); 71 return () => { 72 cancelled = true; 73 }; 74 } 75 76 if (didError) { 77 assign({ loading: false, error: didError, empty: false }); 78 return () => { 79 cancelled = true; 80 }; 81 } 82 83 if (endpointError) { 84 assign({ loading: false, error: endpointError, empty: false }); 85 return () => { 86 cancelled = true; 87 }; 88 } 89 90 if (resolvingDid || resolvingEndpoint || !did || !endpoint) { 91 assign({ loading: true, error: undefined }); 92 return () => { 93 cancelled = true; 94 }; 95 } 96 97 assign({ loading: true, error: undefined, empty: false }); 98 99 (async () => { 100 try { 101 // Slingshot doesn't support listRecords, so we query PDS directly 102 const res = await callListRecords<T>( 103 endpoint, 104 did, 105 collection, 106 3, // Fetch 3 in case some have invalid timestamps 107 ); 108 109 if (!res.ok) { 110 throw new Error("Failed to list records from PDS"); 111 } 112 113 const list = res.data.records; 114 if (list.length === 0) { 115 assign({ 116 loading: false, 117 empty: true, 118 record: undefined, 119 rkey: undefined, 120 }); 121 return; 122 } 123 124 // Find the first valid record (skip records before 2023) 125 const validRecord = list.find((item) => isValidTimestamp(item.value)); 126 127 if (!validRecord) { 128 console.warn("No valid records found (all had timestamps before 2023)"); 129 assign({ 130 loading: false, 131 empty: true, 132 record: undefined, 133 rkey: undefined, 134 }); 135 return; 136 } 137 138 const derivedRkey = validRecord.rkey ?? extractRkey(validRecord.uri); 139 assign({ 140 record: validRecord.value, 141 rkey: derivedRkey, 142 loading: false, 143 empty: false, 144 }); 145 } catch (e) { 146 assign({ error: e as Error, loading: false, empty: false }); 147 } 148 })(); 149 150 return () => { 151 cancelled = true; 152 }; 153 }, [ 154 handleOrDid, 155 did, 156 endpoint, 157 collection, 158 resolvingDid, 159 resolvingEndpoint, 160 didError, 161 endpointError, 162 refreshKey, 163 ]); 164 165 return state; 166} 167 168function extractRkey(uri: string): string | undefined { 169 if (!uri) return undefined; 170 const parts = uri.split("/"); 171 return parts[parts.length - 1]; 172} 173 174/** 175 * Validates that a record has a reasonable timestamp (not before 2023). 176 * ATProto was created in 2023, so any timestamp before that is invalid. 177 */ 178function isValidTimestamp(record: unknown): boolean { 179 if (typeof record !== "object" || record === null) return true; 180 181 const recordObj = record as { createdAt?: string; indexedAt?: string }; 182 const timestamp = recordObj.createdAt || recordObj.indexedAt; 183 184 if (!timestamp || typeof timestamp !== "string") return true; // No timestamp to validate 185 186 try { 187 const date = new Date(timestamp); 188 // ATProto was created in 2023, reject anything before that 189 return date.getFullYear() >= 2023; 190 } catch { 191 // If we can't parse the date, consider it valid to avoid false negatives 192 return true; 193 } 194}