A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
at main 9.4 kB view raw
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;