···11# atproto-ui
2233-A React component library for rendering AT Protocol records (Bluesky, Leaflet, Tangled, and more). Handles DID resolution, PDS discovery, and record fetching automatically. [Live demo](https://atproto-ui.netlify.app).
33+A React component library for rendering AT Protocol records (Bluesky, Leaflet, Tangled, and more). Handles DID resolution, PDS discovery, and record fetching automatically as well as caching these so multiple components can render quickly. [Live demo](https://atproto-ui.netlify.app).
4455This project is mostly a wrapper on the extremely amazing work [Mary](https://mary.my.id/) has done with [atcute](https://tangled.org/@mary.my.id/atcute), please support it. I have to give thanks to [phil](https://bsky.app/profile/bad-example.com) for microcosm and slingshot. Incredible services being given for free that is responsible for why the components fetch data so quickly.
66···11111212## Features
13131414-- **Drop-in components** for common record types (`BlueskyPost`, `BlueskyProfile`, `TangledString`, `LeafletDocument`)
1414+- **Drop-in components** for common record types (`BlueskyPost`, `BlueskyProfile`, `TangledRepo`, `LeafletDocument`)
1515- **Prefetch support** - Pass data directly to skip API calls (perfect for SSR/caching)
1616+- **Caching** - Blobs, DIDs, and records are cached so components which use the same ones can render even quicker
1617- **Customizable theming** - Override CSS variables to match your app's design
1718- **Composable hooks** - Build custom renderers with protocol primitives
1819- Built on lightweight [`@atcute/*`](https://tangled.org/@mary.my.id/atcute) clients
+327
lib/components/GrainGallery.tsx
···11+import React, { useMemo, useEffect, useState } from "react";
22+import { GrainGalleryRenderer, type GrainGalleryPhoto } from "../renderers/GrainGalleryRenderer";
33+import type { GrainGalleryRecord, GrainGalleryItemRecord, GrainPhotoRecord } from "../types/grain";
44+import type { ProfileRecord } from "../types/bluesky";
55+import { useDidResolution } from "../hooks/useDidResolution";
66+import { useAtProtoRecord } from "../hooks/useAtProtoRecord";
77+import { useBacklinks } from "../hooks/useBacklinks";
88+import { useBlob } from "../hooks/useBlob";
99+import { BLUESKY_PROFILE_COLLECTION } from "./BlueskyProfile";
1010+import { getAvatarCid } from "../utils/profile";
1111+import { formatDidForLabel, parseAtUri } from "../utils/at-uri";
1212+import { isBlobWithCdn } from "../utils/blob";
1313+import { createAtprotoClient } from "../utils/atproto-client";
1414+1515+/**
1616+ * Props for rendering a grain.social gallery.
1717+ */
1818+export interface GrainGalleryProps {
1919+ /**
2020+ * Decentralized identifier for the repository that owns the gallery.
2121+ */
2222+ did: string;
2323+ /**
2424+ * Record key identifying the specific gallery within the collection.
2525+ */
2626+ rkey: string;
2727+ /**
2828+ * Prefetched gallery record. When provided, skips fetching the gallery from the network.
2929+ */
3030+ record?: GrainGalleryRecord;
3131+ /**
3232+ * Custom renderer component that receives resolved gallery data and status flags.
3333+ */
3434+ renderer?: React.ComponentType<GrainGalleryRendererInjectedProps>;
3535+ /**
3636+ * React node shown while the gallery query has not yet produced data or an error.
3737+ */
3838+ fallback?: React.ReactNode;
3939+ /**
4040+ * React node displayed while the gallery fetch is actively loading.
4141+ */
4242+ loadingIndicator?: React.ReactNode;
4343+ /**
4444+ * Constellation API base URL for fetching backlinks.
4545+ */
4646+ constellationBaseUrl?: string;
4747+}
4848+4949+/**
5050+ * Values injected by `GrainGallery` into a downstream renderer component.
5151+ */
5252+export type GrainGalleryRendererInjectedProps = {
5353+ /**
5454+ * Resolved gallery record
5555+ */
5656+ gallery: GrainGalleryRecord;
5757+ /**
5858+ * Array of photos in the gallery with their records and metadata
5959+ */
6060+ photos: GrainGalleryPhoto[];
6161+ /**
6262+ * `true` while network operations are in-flight.
6363+ */
6464+ loading: boolean;
6565+ /**
6666+ * Error encountered during loading, if any.
6767+ */
6868+ error?: Error;
6969+ /**
7070+ * The author's public handle derived from the DID.
7171+ */
7272+ authorHandle?: string;
7373+ /**
7474+ * The author's display name from their profile.
7575+ */
7676+ authorDisplayName?: string;
7777+ /**
7878+ * Resolved URL for the author's avatar blob, if available.
7979+ */
8080+ avatarUrl?: string;
8181+};
8282+8383+export const GRAIN_GALLERY_COLLECTION = "social.grain.gallery";
8484+export const GRAIN_GALLERY_ITEM_COLLECTION = "social.grain.gallery.item";
8585+export const GRAIN_PHOTO_COLLECTION = "social.grain.photo";
8686+8787+/**
8888+ * Fetches a grain.social gallery, resolves all photos via constellation backlinks,
8989+ * and renders them in a grid layout.
9090+ *
9191+ * @param did - DID of the repository that stores the gallery.
9292+ * @param rkey - Record key for the gallery.
9393+ * @param record - Prefetched gallery record.
9494+ * @param renderer - Optional renderer component to override the default.
9595+ * @param fallback - Node rendered before the first fetch attempt resolves.
9696+ * @param loadingIndicator - Node rendered while the gallery is loading.
9797+ * @param constellationBaseUrl - Constellation API base URL.
9898+ * @returns A component that renders loading/fallback states and the resolved gallery.
9999+ */
100100+export const GrainGallery: React.FC<GrainGalleryProps> = React.memo(
101101+ ({
102102+ did: handleOrDid,
103103+ rkey,
104104+ record,
105105+ renderer,
106106+ fallback,
107107+ loadingIndicator,
108108+ constellationBaseUrl,
109109+ }) => {
110110+ const {
111111+ did: resolvedDid,
112112+ handle,
113113+ loading: resolvingIdentity,
114114+ error: resolutionError,
115115+ } = useDidResolution(handleOrDid);
116116+117117+ const repoIdentifier = resolvedDid ?? handleOrDid;
118118+119119+ // Fetch author profile
120120+ const { record: profile } = useAtProtoRecord<ProfileRecord>({
121121+ did: repoIdentifier,
122122+ collection: BLUESKY_PROFILE_COLLECTION,
123123+ rkey: "self",
124124+ });
125125+ const avatar = profile?.avatar;
126126+ const avatarCdnUrl = isBlobWithCdn(avatar) ? avatar.cdnUrl : undefined;
127127+ const avatarCid = avatarCdnUrl ? undefined : getAvatarCid(profile);
128128+ const authorDisplayName = profile?.displayName;
129129+ const { url: avatarUrlFromBlob } = useBlob(repoIdentifier, avatarCid);
130130+ const avatarUrl = avatarCdnUrl || avatarUrlFromBlob;
131131+132132+ // Fetch gallery record
133133+ const {
134134+ record: fetchedGallery,
135135+ loading: galleryLoading,
136136+ error: galleryError,
137137+ } = useAtProtoRecord<GrainGalleryRecord>({
138138+ did: record ? "" : repoIdentifier,
139139+ collection: record ? "" : GRAIN_GALLERY_COLLECTION,
140140+ rkey: record ? "" : rkey,
141141+ });
142142+143143+ const galleryRecord = record ?? fetchedGallery;
144144+ const galleryUri = resolvedDid
145145+ ? `at://${resolvedDid}/${GRAIN_GALLERY_COLLECTION}/${rkey}`
146146+ : undefined;
147147+148148+ // Fetch backlinks to get gallery items
149149+ const {
150150+ backlinks,
151151+ loading: backlinksLoading,
152152+ error: backlinksError,
153153+ } = useBacklinks({
154154+ subject: galleryUri || "",
155155+ source: `${GRAIN_GALLERY_ITEM_COLLECTION}:gallery`,
156156+ enabled: !!galleryUri && !!galleryRecord,
157157+ constellationBaseUrl,
158158+ });
159159+160160+ // Fetch all gallery item records and photo records
161161+ const [photos, setPhotos] = useState<GrainGalleryPhoto[]>([]);
162162+ const [photosLoading, setPhotosLoading] = useState(false);
163163+ const [photosError, setPhotosError] = useState<Error | undefined>(undefined);
164164+165165+ useEffect(() => {
166166+ if (!backlinks || backlinks.length === 0) {
167167+ setPhotos([]);
168168+ return;
169169+ }
170170+171171+ let cancelled = false;
172172+ setPhotosLoading(true);
173173+ setPhotosError(undefined);
174174+175175+ (async () => {
176176+ try {
177177+ const photoPromises = backlinks.map(async (backlink) => {
178178+ // Create client for gallery item DID (uses slingshot + PDS fallback)
179179+ const { rpc: galleryItemClient } = await createAtprotoClient({
180180+ did: backlink.did,
181181+ });
182182+183183+ // Fetch gallery item record
184184+ const galleryItemRes = await (
185185+ galleryItemClient as unknown as {
186186+ get: (
187187+ nsid: string,
188188+ opts: {
189189+ params: {
190190+ repo: string;
191191+ collection: string;
192192+ rkey: string;
193193+ };
194194+ },
195195+ ) => Promise<{ ok: boolean; data: { value: GrainGalleryItemRecord } }>;
196196+ }
197197+ ).get("com.atproto.repo.getRecord", {
198198+ params: {
199199+ repo: backlink.did,
200200+ collection: GRAIN_GALLERY_ITEM_COLLECTION,
201201+ rkey: backlink.rkey,
202202+ },
203203+ });
204204+205205+ if (!galleryItemRes.ok) return null;
206206+207207+ const galleryItem = galleryItemRes.data.value;
208208+209209+ // Parse photo URI
210210+ const photoUri = parseAtUri(galleryItem.item);
211211+ if (!photoUri) return null;
212212+213213+ // Create client for photo DID (uses slingshot + PDS fallback)
214214+ const { rpc: photoClient } = await createAtprotoClient({
215215+ did: photoUri.did,
216216+ });
217217+218218+ // Fetch photo record
219219+ const photoRes = await (
220220+ photoClient as unknown as {
221221+ get: (
222222+ nsid: string,
223223+ opts: {
224224+ params: {
225225+ repo: string;
226226+ collection: string;
227227+ rkey: string;
228228+ };
229229+ },
230230+ ) => Promise<{ ok: boolean; data: { value: GrainPhotoRecord } }>;
231231+ }
232232+ ).get("com.atproto.repo.getRecord", {
233233+ params: {
234234+ repo: photoUri.did,
235235+ collection: photoUri.collection,
236236+ rkey: photoUri.rkey,
237237+ },
238238+ });
239239+240240+ if (!photoRes.ok) return null;
241241+242242+ const photoRecord = photoRes.data.value;
243243+244244+ return {
245245+ record: photoRecord,
246246+ did: photoUri.did,
247247+ rkey: photoUri.rkey,
248248+ position: galleryItem.position,
249249+ } as GrainGalleryPhoto;
250250+ });
251251+252252+ const resolvedPhotos = await Promise.all(photoPromises);
253253+ const validPhotos = resolvedPhotos.filter((p): p is NonNullable<typeof p> => p !== null) as GrainGalleryPhoto[];
254254+255255+ if (!cancelled) {
256256+ setPhotos(validPhotos);
257257+ setPhotosLoading(false);
258258+ }
259259+ } catch (err) {
260260+ if (!cancelled) {
261261+ setPhotosError(err instanceof Error ? err : new Error("Failed to fetch photos"));
262262+ setPhotosLoading(false);
263263+ }
264264+ }
265265+ })();
266266+267267+ return () => {
268268+ cancelled = true;
269269+ };
270270+ }, [backlinks]);
271271+272272+ const Comp: React.ComponentType<GrainGalleryRendererInjectedProps> =
273273+ useMemo(
274274+ () =>
275275+ renderer ?? ((props) => <GrainGalleryRenderer {...props} />),
276276+ [renderer],
277277+ );
278278+279279+ const displayHandle =
280280+ handle ??
281281+ (handleOrDid.startsWith("did:") ? undefined : handleOrDid);
282282+ const authorHandle =
283283+ displayHandle ?? formatDidForLabel(resolvedDid ?? handleOrDid);
284284+285285+ if (!displayHandle && resolvingIdentity) {
286286+ return loadingIndicator || <div style={{ padding: 8 }}>Resolving handle…</div>;
287287+ }
288288+ if (!displayHandle && resolutionError) {
289289+ return (
290290+ <div style={{ padding: 8, color: "crimson" }}>
291291+ Could not resolve handle.
292292+ </div>
293293+ );
294294+ }
295295+296296+ if (galleryError || backlinksError || photosError) {
297297+ return (
298298+ <div style={{ padding: 8, color: "crimson" }}>
299299+ Failed to load gallery.
300300+ </div>
301301+ );
302302+ }
303303+304304+ if (!galleryRecord && galleryLoading) {
305305+ return loadingIndicator || <div style={{ padding: 8 }}>Loading gallery…</div>;
306306+ }
307307+308308+ if (!galleryRecord) {
309309+ return fallback || <div style={{ padding: 8 }}>Gallery not found.</div>;
310310+ }
311311+312312+ const loading = galleryLoading || backlinksLoading || photosLoading;
313313+314314+ return (
315315+ <Comp
316316+ gallery={galleryRecord}
317317+ photos={photos}
318318+ loading={loading}
319319+ authorHandle={authorHandle}
320320+ authorDisplayName={authorDisplayName}
321321+ avatarUrl={avatarUrl}
322322+ />
323323+ );
324324+ },
325325+);
326326+327327+export default GrainGallery;
+3
lib/index.ts
···1212export * from "./components/BlueskyPostList";
1313export * from "./components/BlueskyProfile";
1414export * from "./components/BlueskyQuotePost";
1515+export * from "./components/GrainGallery";
1516export * from "./components/LeafletDocument";
1617export * from "./components/TangledRepo";
1718export * from "./components/TangledString";
···3132// Renderers
3233export * from "./renderers/BlueskyPostRenderer";
3334export * from "./renderers/BlueskyProfileRenderer";
3535+export * from "./renderers/GrainGalleryRenderer";
3436export * from "./renderers/LeafletDocumentRenderer";
3537export * from "./renderers/TangledRepoRenderer";
3638export * from "./renderers/TangledStringRenderer";
37393840// Types
3941export * from "./types/bluesky";
4242+export * from "./types/grain";
4043export * from "./types/leaflet";
4144export * from "./types/tangled";
4245export * from "./types/theme";
···11+/**
22+ * Type definitions for grain.social records
33+ * Uses standard atcute blob types for compatibility
44+ */
55+import type { Blob } from "@atcute/lexicons/interfaces";
66+import type { BlobWithCdn } from "../hooks/useBlueskyAppview";
77+88+/**
99+ * grain.social gallery record
1010+ * A container for a collection of photos
1111+ */
1212+export interface GrainGalleryRecord {
1313+ /**
1414+ * Record type identifier
1515+ */
1616+ $type: "social.grain.gallery";
1717+ /**
1818+ * Gallery title
1919+ */
2020+ title: string;
2121+ /**
2222+ * Gallery description
2323+ */
2424+ description?: string;
2525+ /**
2626+ * Self-label values (content warnings)
2727+ */
2828+ labels?: {
2929+ $type: "com.atproto.label.defs#selfLabels";
3030+ values: Array<{ val: string }>;
3131+ };
3232+ /**
3333+ * Timestamp when the gallery was created
3434+ */
3535+ createdAt: string;
3636+}
3737+3838+/**
3939+ * grain.social gallery item record
4040+ * Links a photo to a gallery
4141+ */
4242+export interface GrainGalleryItemRecord {
4343+ /**
4444+ * Record type identifier
4545+ */
4646+ $type: "social.grain.gallery.item";
4747+ /**
4848+ * AT URI of the photo (social.grain.photo)
4949+ */
5050+ item: string;
5151+ /**
5252+ * AT URI of the gallery this item belongs to
5353+ */
5454+ gallery: string;
5555+ /**
5656+ * Position/order within the gallery
5757+ */
5858+ position?: number;
5959+ /**
6060+ * Timestamp when the item was added to the gallery
6161+ */
6262+ createdAt: string;
6363+}
6464+6565+/**
6666+ * grain.social photo record
6767+ * Compatible with records from @atcute clients
6868+ */
6969+export interface GrainPhotoRecord {
7070+ /**
7171+ * Record type identifier
7272+ */
7373+ $type: "social.grain.photo";
7474+ /**
7575+ * Alt text description of the image (required for accessibility)
7676+ */
7777+ alt: string;
7878+ /**
7979+ * Photo blob reference - uses standard AT Proto blob format
8080+ * Supports any image/* mime type
8181+ * May include cdnUrl when fetched from appview
8282+ */
8383+ photo: Blob<`image/${string}`> | BlobWithCdn;
8484+ /**
8585+ * Timestamp when the photo was created
8686+ */
8787+ createdAt?: string;
8888+ /**
8989+ * Aspect ratio of the photo
9090+ */
9191+ aspectRatio?: {
9292+ width: number;
9393+ height: number;
9494+ };
9595+}