A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import React, { useMemo, useEffect, useState } from "react";
2import { GrainGalleryRenderer, type GrainGalleryPhoto } from "../renderers/GrainGalleryRenderer";
3import type { GrainGalleryRecord, GrainGalleryItemRecord, GrainPhotoRecord } from "../types/grain";
4import type { ProfileRecord } from "../types/bluesky";
5import { useDidResolution } from "../hooks/useDidResolution";
6import { useAtProtoRecord } from "../hooks/useAtProtoRecord";
7import { useBacklinks } from "../hooks/useBacklinks";
8import { useBlob } from "../hooks/useBlob";
9import { BLUESKY_PROFILE_COLLECTION } from "./BlueskyProfile";
10import { getAvatarCid } from "../utils/profile";
11import { formatDidForLabel, parseAtUri } from "../utils/at-uri";
12import { isBlobWithCdn } from "../utils/blob";
13import { createAtprotoClient } from "../utils/atproto-client";
14
15/**
16 * Props for rendering a grain.social gallery.
17 */
18export interface GrainGalleryProps {
19 /**
20 * Decentralized identifier for the repository that owns the gallery.
21 */
22 did: string;
23 /**
24 * Record key identifying the specific gallery within the collection.
25 */
26 rkey: string;
27 /**
28 * Prefetched gallery record. When provided, skips fetching the gallery from the network.
29 */
30 record?: GrainGalleryRecord;
31 /**
32 * Custom renderer component that receives resolved gallery data and status flags.
33 */
34 renderer?: React.ComponentType<GrainGalleryRendererInjectedProps>;
35 /**
36 * React node shown while the gallery query has not yet produced data or an error.
37 */
38 fallback?: React.ReactNode;
39 /**
40 * React node displayed while the gallery fetch is actively loading.
41 */
42 loadingIndicator?: React.ReactNode;
43 /**
44 * Constellation API base URL for fetching backlinks.
45 */
46 constellationBaseUrl?: string;
47}
48
49/**
50 * Values injected by `GrainGallery` into a downstream renderer component.
51 */
52export type GrainGalleryRendererInjectedProps = {
53 /**
54 * Resolved gallery record
55 */
56 gallery: GrainGalleryRecord;
57 /**
58 * Array of photos in the gallery with their records and metadata
59 */
60 photos: GrainGalleryPhoto[];
61 /**
62 * `true` while network operations are in-flight.
63 */
64 loading: boolean;
65 /**
66 * Error encountered during loading, if any.
67 */
68 error?: Error;
69 /**
70 * The author's public handle derived from the DID.
71 */
72 authorHandle?: string;
73 /**
74 * The author's display name from their profile.
75 */
76 authorDisplayName?: string;
77 /**
78 * Resolved URL for the author's avatar blob, if available.
79 */
80 avatarUrl?: string;
81};
82
83export const GRAIN_GALLERY_COLLECTION = "social.grain.gallery";
84export const GRAIN_GALLERY_ITEM_COLLECTION = "social.grain.gallery.item";
85export const GRAIN_PHOTO_COLLECTION = "social.grain.photo";
86
87/**
88 * Fetches a grain.social gallery, resolves all photos via constellation backlinks,
89 * and renders them in a grid layout.
90 *
91 * @param did - DID of the repository that stores the gallery.
92 * @param rkey - Record key for the gallery.
93 * @param record - Prefetched gallery record.
94 * @param renderer - Optional renderer component to override the default.
95 * @param fallback - Node rendered before the first fetch attempt resolves.
96 * @param loadingIndicator - Node rendered while the gallery is loading.
97 * @param constellationBaseUrl - Constellation API base URL.
98 * @returns A component that renders loading/fallback states and the resolved gallery.
99 */
100export const GrainGallery: React.FC<GrainGalleryProps> = React.memo(
101 ({
102 did: handleOrDid,
103 rkey,
104 record,
105 renderer,
106 fallback,
107 loadingIndicator,
108 constellationBaseUrl,
109 }) => {
110 const {
111 did: resolvedDid,
112 handle,
113 loading: resolvingIdentity,
114 error: resolutionError,
115 } = useDidResolution(handleOrDid);
116
117 const repoIdentifier = resolvedDid ?? handleOrDid;
118
119 // Fetch author profile
120 const { record: profile } = useAtProtoRecord<ProfileRecord>({
121 did: repoIdentifier,
122 collection: BLUESKY_PROFILE_COLLECTION,
123 rkey: "self",
124 });
125 const avatar = profile?.avatar;
126 const avatarCdnUrl = isBlobWithCdn(avatar) ? avatar.cdnUrl : undefined;
127 const avatarCid = avatarCdnUrl ? undefined : getAvatarCid(profile);
128 const authorDisplayName = profile?.displayName;
129 const { url: avatarUrlFromBlob } = useBlob(repoIdentifier, avatarCid);
130 const avatarUrl = avatarCdnUrl || avatarUrlFromBlob;
131
132 // Fetch gallery record
133 const {
134 record: fetchedGallery,
135 loading: galleryLoading,
136 error: galleryError,
137 } = useAtProtoRecord<GrainGalleryRecord>({
138 did: record ? "" : repoIdentifier,
139 collection: record ? "" : GRAIN_GALLERY_COLLECTION,
140 rkey: record ? "" : rkey,
141 });
142
143 const galleryRecord = record ?? fetchedGallery;
144 const galleryUri = resolvedDid
145 ? `at://${resolvedDid}/${GRAIN_GALLERY_COLLECTION}/${rkey}`
146 : undefined;
147
148 // Fetch backlinks to get gallery items
149 const {
150 backlinks,
151 loading: backlinksLoading,
152 error: backlinksError,
153 } = useBacklinks({
154 subject: galleryUri || "",
155 source: `${GRAIN_GALLERY_ITEM_COLLECTION}:gallery`,
156 enabled: !!galleryUri && !!galleryRecord,
157 constellationBaseUrl,
158 });
159
160 // Fetch all gallery item records and photo records
161 const [photos, setPhotos] = useState<GrainGalleryPhoto[]>([]);
162 const [photosLoading, setPhotosLoading] = useState(false);
163 const [photosError, setPhotosError] = useState<Error | undefined>(undefined);
164
165 useEffect(() => {
166 if (!backlinks || backlinks.length === 0) {
167 setPhotos([]);
168 return;
169 }
170
171 let cancelled = false;
172 setPhotosLoading(true);
173 setPhotosError(undefined);
174
175 (async () => {
176 try {
177 const photoPromises = backlinks.map(async (backlink) => {
178 // Create client for gallery item DID (uses slingshot + PDS fallback)
179 const { rpc: galleryItemClient } = await createAtprotoClient({
180 did: backlink.did,
181 });
182
183 // Fetch gallery item record
184 const galleryItemRes = await (
185 galleryItemClient as unknown as {
186 get: (
187 nsid: string,
188 opts: {
189 params: {
190 repo: string;
191 collection: string;
192 rkey: string;
193 };
194 },
195 ) => Promise<{ ok: boolean; data: { value: GrainGalleryItemRecord } }>;
196 }
197 ).get("com.atproto.repo.getRecord", {
198 params: {
199 repo: backlink.did,
200 collection: GRAIN_GALLERY_ITEM_COLLECTION,
201 rkey: backlink.rkey,
202 },
203 });
204
205 if (!galleryItemRes.ok) return null;
206
207 const galleryItem = galleryItemRes.data.value;
208
209 // Parse photo URI
210 const photoUri = parseAtUri(galleryItem.item);
211 if (!photoUri) return null;
212
213 // Create client for photo DID (uses slingshot + PDS fallback)
214 const { rpc: photoClient } = await createAtprotoClient({
215 did: photoUri.did,
216 });
217
218 // Fetch photo record
219 const photoRes = await (
220 photoClient as unknown as {
221 get: (
222 nsid: string,
223 opts: {
224 params: {
225 repo: string;
226 collection: string;
227 rkey: string;
228 };
229 },
230 ) => Promise<{ ok: boolean; data: { value: GrainPhotoRecord } }>;
231 }
232 ).get("com.atproto.repo.getRecord", {
233 params: {
234 repo: photoUri.did,
235 collection: photoUri.collection,
236 rkey: photoUri.rkey,
237 },
238 });
239
240 if (!photoRes.ok) return null;
241
242 const photoRecord = photoRes.data.value;
243
244 return {
245 record: photoRecord,
246 did: photoUri.did,
247 rkey: photoUri.rkey,
248 position: galleryItem.position,
249 } as GrainGalleryPhoto;
250 });
251
252 const resolvedPhotos = await Promise.all(photoPromises);
253 const validPhotos = resolvedPhotos.filter((p): p is NonNullable<typeof p> => p !== null) as GrainGalleryPhoto[];
254
255 if (!cancelled) {
256 setPhotos(validPhotos);
257 setPhotosLoading(false);
258 }
259 } catch (err) {
260 if (!cancelled) {
261 setPhotosError(err instanceof Error ? err : new Error("Failed to fetch photos"));
262 setPhotosLoading(false);
263 }
264 }
265 })();
266
267 return () => {
268 cancelled = true;
269 };
270 }, [backlinks]);
271
272 const Comp: React.ComponentType<GrainGalleryRendererInjectedProps> =
273 useMemo(
274 () =>
275 renderer ?? ((props) => <GrainGalleryRenderer {...props} />),
276 [renderer],
277 );
278
279 const displayHandle =
280 handle ??
281 (handleOrDid.startsWith("did:") ? undefined : handleOrDid);
282 const authorHandle =
283 displayHandle ?? formatDidForLabel(resolvedDid ?? handleOrDid);
284
285 if (!displayHandle && resolvingIdentity) {
286 return loadingIndicator || <div role="status" aria-live="polite" style={{ padding: 8 }}>Resolving handle…</div>;
287 }
288 if (!displayHandle && resolutionError) {
289 return (
290 <div style={{ padding: 8, color: "crimson" }}>
291 Could not resolve handle.
292 </div>
293 );
294 }
295
296 if (galleryError || backlinksError || photosError) {
297 return (
298 <div style={{ padding: 8, color: "crimson" }}>
299 Failed to load gallery.
300 </div>
301 );
302 }
303
304 if (!galleryRecord && galleryLoading) {
305 return loadingIndicator || <div style={{ padding: 8 }}>Loading gallery…</div>;
306 }
307
308 if (!galleryRecord) {
309 return fallback || <div style={{ padding: 8 }}>Gallery not found.</div>;
310 }
311
312 const loading = galleryLoading || backlinksLoading || photosLoading;
313
314 return (
315 <Comp
316 gallery={galleryRecord}
317 photos={photos}
318 loading={loading}
319 authorHandle={authorHandle}
320 authorDisplayName={authorDisplayName}
321 avatarUrl={avatarUrl}
322 />
323 );
324 },
325);
326
327export default GrainGallery;