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 * @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}