A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import { useEffect, useState, useCallback, useRef } from "react";
2
3/**
4 * Individual backlink record returned by Microcosm Constellation.
5 */
6export interface BacklinkRecord {
7 /** DID of the author who created the backlink. */
8 did: string;
9 /** Collection type of the backlink record (e.g., "sh.tangled.feed.star"). */
10 collection: string;
11 /** Record key of the backlink. */
12 rkey: string;
13}
14
15/**
16 * Response from Microcosm Constellation API.
17 */
18export interface BacklinksResponse {
19 /** Total count of backlinks. */
20 total: number;
21 /** Array of backlink records. */
22 records: BacklinkRecord[];
23 /** Cursor for pagination (optional). */
24 cursor?: string;
25}
26
27/**
28 * Parameters for fetching backlinks.
29 */
30export interface UseBacklinksParams {
31 /** The AT-URI subject to get backlinks for (e.g., "at://did:plc:xxx/sh.tangled.repo/yyy"). */
32 subject: string;
33 /** The source collection and path (e.g., "sh.tangled.feed.star:subject"). */
34 source: string;
35 /** Maximum number of results to fetch (default: 16, max: 100). */
36 limit?: number;
37 /** Base URL for the Microcosm Constellation API. */
38 constellationBaseUrl?: string;
39 /** Whether to automatically fetch backlinks on mount. */
40 enabled?: boolean;
41}
42
43const DEFAULT_CONSTELLATION = "https://constellation.microcosm.blue";
44
45/**
46 * Hook to fetch backlinks from Microcosm Constellation API.
47 *
48 * Backlinks are records that reference another record. For example,
49 * `sh.tangled.feed.star` records are backlinks to `sh.tangled.repo` records,
50 * representing users who have starred a repository.
51 *
52 * @param params - Configuration for fetching backlinks
53 * @returns Object containing backlinks data, loading state, error, and refetch function
54 *
55 * @example
56 * ```tsx
57 * const { backlinks, loading, error, count } = useBacklinks({
58 * subject: "at://did:plc:example/sh.tangled.repo/3k2aexample",
59 * source: "sh.tangled.feed.star:subject",
60 * });
61 * ```
62 */
63export function useBacklinks({
64 subject,
65 source,
66 limit = 16,
67 constellationBaseUrl = DEFAULT_CONSTELLATION,
68 enabled = true,
69}: UseBacklinksParams) {
70 const [backlinks, setBacklinks] = useState<BacklinkRecord[]>([]);
71 const [total, setTotal] = useState(0);
72 const [loading, setLoading] = useState(false);
73 const [error, setError] = useState<Error | undefined>(undefined);
74 const [cursor, setCursor] = useState<string | undefined>(undefined);
75 const abortControllerRef = useRef<AbortController | null>(null);
76
77 const fetchBacklinks = useCallback(
78 async (signal?: AbortSignal) => {
79 if (!subject || !source || !enabled) return;
80
81 try {
82 setLoading(true);
83 setError(undefined);
84
85 const baseUrl = constellationBaseUrl.endsWith("/")
86 ? constellationBaseUrl.slice(0, -1)
87 : constellationBaseUrl;
88
89 const params = new URLSearchParams({
90 subject: subject,
91 source: source,
92 limit: limit.toString(),
93 });
94
95 const url = `${baseUrl}/xrpc/blue.microcosm.links.getBacklinks?${params}`;
96
97 const response = await fetch(url, { signal });
98
99 if (!response.ok) {
100 throw new Error(
101 `Failed to fetch backlinks: ${response.status} ${response.statusText}`,
102 );
103 }
104
105 const data: BacklinksResponse = await response.json();
106 setBacklinks(data.records || []);
107 setTotal(data.total || 0);
108 setCursor(data.cursor);
109 } catch (err) {
110 if (err instanceof Error && err.name === "AbortError") {
111 // Ignore abort errors
112 return;
113 }
114 setError(
115 err instanceof Error ? err : new Error("Unknown error fetching backlinks"),
116 );
117 } finally {
118 setLoading(false);
119 }
120 },
121 [subject, source, limit, constellationBaseUrl, enabled],
122 );
123
124 const refetch = useCallback(() => {
125 // Abort any in-flight request
126 if (abortControllerRef.current) {
127 abortControllerRef.current.abort();
128 }
129
130 const controller = new AbortController();
131 abortControllerRef.current = controller;
132 fetchBacklinks(controller.signal);
133 }, [fetchBacklinks]);
134
135 useEffect(() => {
136 if (!enabled) return;
137
138 const controller = new AbortController();
139 abortControllerRef.current = controller;
140 fetchBacklinks(controller.signal);
141
142 return () => {
143 controller.abort();
144 };
145 }, [fetchBacklinks, enabled]);
146
147 return {
148 /** Array of backlink records. */
149 backlinks,
150 /** Whether backlinks are currently being fetched. */
151 loading,
152 /** Error if fetch failed. */
153 error,
154 /** Pagination cursor (not yet implemented for pagination). */
155 cursor,
156 /** Total count of backlinks from the API. */
157 total,
158 /** Total count of backlinks (alias for total). */
159 count: total,
160 /** Function to manually refetch backlinks. */
161 refetch,
162 };
163}