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}