A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.

grain.social component

Changed files
+1211 -2
lib
src
+3 -2
README.md
···
# atproto-ui
-
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).
+
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).
This 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.
···
## Features
-
- **Drop-in components** for common record types (`BlueskyPost`, `BlueskyProfile`, `TangledString`, `LeafletDocument`)
+
- **Drop-in components** for common record types (`BlueskyPost`, `BlueskyProfile`, `TangledRepo`, `LeafletDocument`)
- **Prefetch support** - Pass data directly to skip API calls (perfect for SSR/caching)
+
- **Caching** - Blobs, DIDs, and records are cached so components which use the same ones can render even quicker
- **Customizable theming** - Override CSS variables to match your app's design
- **Composable hooks** - Build custom renderers with protocol primitives
- Built on lightweight [`@atcute/*`](https://tangled.org/@mary.my.id/atcute) clients
+327
lib/components/GrainGallery.tsx
···
+
import React, { useMemo, useEffect, useState } from "react";
+
import { GrainGalleryRenderer, type GrainGalleryPhoto } from "../renderers/GrainGalleryRenderer";
+
import type { GrainGalleryRecord, GrainGalleryItemRecord, GrainPhotoRecord } from "../types/grain";
+
import type { ProfileRecord } from "../types/bluesky";
+
import { useDidResolution } from "../hooks/useDidResolution";
+
import { useAtProtoRecord } from "../hooks/useAtProtoRecord";
+
import { useBacklinks } from "../hooks/useBacklinks";
+
import { useBlob } from "../hooks/useBlob";
+
import { BLUESKY_PROFILE_COLLECTION } from "./BlueskyProfile";
+
import { getAvatarCid } from "../utils/profile";
+
import { formatDidForLabel, parseAtUri } from "../utils/at-uri";
+
import { isBlobWithCdn } from "../utils/blob";
+
import { createAtprotoClient } from "../utils/atproto-client";
+
+
/**
+
* Props for rendering a grain.social gallery.
+
*/
+
export interface GrainGalleryProps {
+
/**
+
* Decentralized identifier for the repository that owns the gallery.
+
*/
+
did: string;
+
/**
+
* Record key identifying the specific gallery within the collection.
+
*/
+
rkey: string;
+
/**
+
* Prefetched gallery record. When provided, skips fetching the gallery from the network.
+
*/
+
record?: GrainGalleryRecord;
+
/**
+
* Custom renderer component that receives resolved gallery data and status flags.
+
*/
+
renderer?: React.ComponentType<GrainGalleryRendererInjectedProps>;
+
/**
+
* React node shown while the gallery query has not yet produced data or an error.
+
*/
+
fallback?: React.ReactNode;
+
/**
+
* React node displayed while the gallery fetch is actively loading.
+
*/
+
loadingIndicator?: React.ReactNode;
+
/**
+
* Constellation API base URL for fetching backlinks.
+
*/
+
constellationBaseUrl?: string;
+
}
+
+
/**
+
* Values injected by `GrainGallery` into a downstream renderer component.
+
*/
+
export type GrainGalleryRendererInjectedProps = {
+
/**
+
* Resolved gallery record
+
*/
+
gallery: GrainGalleryRecord;
+
/**
+
* Array of photos in the gallery with their records and metadata
+
*/
+
photos: GrainGalleryPhoto[];
+
/**
+
* `true` while network operations are in-flight.
+
*/
+
loading: boolean;
+
/**
+
* Error encountered during loading, if any.
+
*/
+
error?: Error;
+
/**
+
* The author's public handle derived from the DID.
+
*/
+
authorHandle?: string;
+
/**
+
* The author's display name from their profile.
+
*/
+
authorDisplayName?: string;
+
/**
+
* Resolved URL for the author's avatar blob, if available.
+
*/
+
avatarUrl?: string;
+
};
+
+
export const GRAIN_GALLERY_COLLECTION = "social.grain.gallery";
+
export const GRAIN_GALLERY_ITEM_COLLECTION = "social.grain.gallery.item";
+
export const GRAIN_PHOTO_COLLECTION = "social.grain.photo";
+
+
/**
+
* Fetches a grain.social gallery, resolves all photos via constellation backlinks,
+
* and renders them in a grid layout.
+
*
+
* @param did - DID of the repository that stores the gallery.
+
* @param rkey - Record key for the gallery.
+
* @param record - Prefetched gallery record.
+
* @param renderer - Optional renderer component to override the default.
+
* @param fallback - Node rendered before the first fetch attempt resolves.
+
* @param loadingIndicator - Node rendered while the gallery is loading.
+
* @param constellationBaseUrl - Constellation API base URL.
+
* @returns A component that renders loading/fallback states and the resolved gallery.
+
*/
+
export const GrainGallery: React.FC<GrainGalleryProps> = React.memo(
+
({
+
did: handleOrDid,
+
rkey,
+
record,
+
renderer,
+
fallback,
+
loadingIndicator,
+
constellationBaseUrl,
+
}) => {
+
const {
+
did: resolvedDid,
+
handle,
+
loading: resolvingIdentity,
+
error: resolutionError,
+
} = useDidResolution(handleOrDid);
+
+
const repoIdentifier = resolvedDid ?? handleOrDid;
+
+
// Fetch author profile
+
const { record: profile } = useAtProtoRecord<ProfileRecord>({
+
did: repoIdentifier,
+
collection: BLUESKY_PROFILE_COLLECTION,
+
rkey: "self",
+
});
+
const avatar = profile?.avatar;
+
const avatarCdnUrl = isBlobWithCdn(avatar) ? avatar.cdnUrl : undefined;
+
const avatarCid = avatarCdnUrl ? undefined : getAvatarCid(profile);
+
const authorDisplayName = profile?.displayName;
+
const { url: avatarUrlFromBlob } = useBlob(repoIdentifier, avatarCid);
+
const avatarUrl = avatarCdnUrl || avatarUrlFromBlob;
+
+
// Fetch gallery record
+
const {
+
record: fetchedGallery,
+
loading: galleryLoading,
+
error: galleryError,
+
} = useAtProtoRecord<GrainGalleryRecord>({
+
did: record ? "" : repoIdentifier,
+
collection: record ? "" : GRAIN_GALLERY_COLLECTION,
+
rkey: record ? "" : rkey,
+
});
+
+
const galleryRecord = record ?? fetchedGallery;
+
const galleryUri = resolvedDid
+
? `at://${resolvedDid}/${GRAIN_GALLERY_COLLECTION}/${rkey}`
+
: undefined;
+
+
// Fetch backlinks to get gallery items
+
const {
+
backlinks,
+
loading: backlinksLoading,
+
error: backlinksError,
+
} = useBacklinks({
+
subject: galleryUri || "",
+
source: `${GRAIN_GALLERY_ITEM_COLLECTION}:gallery`,
+
enabled: !!galleryUri && !!galleryRecord,
+
constellationBaseUrl,
+
});
+
+
// Fetch all gallery item records and photo records
+
const [photos, setPhotos] = useState<GrainGalleryPhoto[]>([]);
+
const [photosLoading, setPhotosLoading] = useState(false);
+
const [photosError, setPhotosError] = useState<Error | undefined>(undefined);
+
+
useEffect(() => {
+
if (!backlinks || backlinks.length === 0) {
+
setPhotos([]);
+
return;
+
}
+
+
let cancelled = false;
+
setPhotosLoading(true);
+
setPhotosError(undefined);
+
+
(async () => {
+
try {
+
const photoPromises = backlinks.map(async (backlink) => {
+
// Create client for gallery item DID (uses slingshot + PDS fallback)
+
const { rpc: galleryItemClient } = await createAtprotoClient({
+
did: backlink.did,
+
});
+
+
// Fetch gallery item record
+
const galleryItemRes = await (
+
galleryItemClient as unknown as {
+
get: (
+
nsid: string,
+
opts: {
+
params: {
+
repo: string;
+
collection: string;
+
rkey: string;
+
};
+
},
+
) => Promise<{ ok: boolean; data: { value: GrainGalleryItemRecord } }>;
+
}
+
).get("com.atproto.repo.getRecord", {
+
params: {
+
repo: backlink.did,
+
collection: GRAIN_GALLERY_ITEM_COLLECTION,
+
rkey: backlink.rkey,
+
},
+
});
+
+
if (!galleryItemRes.ok) return null;
+
+
const galleryItem = galleryItemRes.data.value;
+
+
// Parse photo URI
+
const photoUri = parseAtUri(galleryItem.item);
+
if (!photoUri) return null;
+
+
// Create client for photo DID (uses slingshot + PDS fallback)
+
const { rpc: photoClient } = await createAtprotoClient({
+
did: photoUri.did,
+
});
+
+
// Fetch photo record
+
const photoRes = await (
+
photoClient as unknown as {
+
get: (
+
nsid: string,
+
opts: {
+
params: {
+
repo: string;
+
collection: string;
+
rkey: string;
+
};
+
},
+
) => Promise<{ ok: boolean; data: { value: GrainPhotoRecord } }>;
+
}
+
).get("com.atproto.repo.getRecord", {
+
params: {
+
repo: photoUri.did,
+
collection: photoUri.collection,
+
rkey: photoUri.rkey,
+
},
+
});
+
+
if (!photoRes.ok) return null;
+
+
const photoRecord = photoRes.data.value;
+
+
return {
+
record: photoRecord,
+
did: photoUri.did,
+
rkey: photoUri.rkey,
+
position: galleryItem.position,
+
} as GrainGalleryPhoto;
+
});
+
+
const resolvedPhotos = await Promise.all(photoPromises);
+
const validPhotos = resolvedPhotos.filter((p): p is NonNullable<typeof p> => p !== null) as GrainGalleryPhoto[];
+
+
if (!cancelled) {
+
setPhotos(validPhotos);
+
setPhotosLoading(false);
+
}
+
} catch (err) {
+
if (!cancelled) {
+
setPhotosError(err instanceof Error ? err : new Error("Failed to fetch photos"));
+
setPhotosLoading(false);
+
}
+
}
+
})();
+
+
return () => {
+
cancelled = true;
+
};
+
}, [backlinks]);
+
+
const Comp: React.ComponentType<GrainGalleryRendererInjectedProps> =
+
useMemo(
+
() =>
+
renderer ?? ((props) => <GrainGalleryRenderer {...props} />),
+
[renderer],
+
);
+
+
const displayHandle =
+
handle ??
+
(handleOrDid.startsWith("did:") ? undefined : handleOrDid);
+
const authorHandle =
+
displayHandle ?? formatDidForLabel(resolvedDid ?? handleOrDid);
+
+
if (!displayHandle && resolvingIdentity) {
+
return loadingIndicator || <div style={{ padding: 8 }}>Resolving handle…</div>;
+
}
+
if (!displayHandle && resolutionError) {
+
return (
+
<div style={{ padding: 8, color: "crimson" }}>
+
Could not resolve handle.
+
</div>
+
);
+
}
+
+
if (galleryError || backlinksError || photosError) {
+
return (
+
<div style={{ padding: 8, color: "crimson" }}>
+
Failed to load gallery.
+
</div>
+
);
+
}
+
+
if (!galleryRecord && galleryLoading) {
+
return loadingIndicator || <div style={{ padding: 8 }}>Loading gallery…</div>;
+
}
+
+
if (!galleryRecord) {
+
return fallback || <div style={{ padding: 8 }}>Gallery not found.</div>;
+
}
+
+
const loading = galleryLoading || backlinksLoading || photosLoading;
+
+
return (
+
<Comp
+
gallery={galleryRecord}
+
photos={photos}
+
loading={loading}
+
authorHandle={authorHandle}
+
authorDisplayName={authorDisplayName}
+
avatarUrl={avatarUrl}
+
/>
+
);
+
},
+
);
+
+
export default GrainGallery;
+3
lib/index.ts
···
export * from "./components/BlueskyPostList";
export * from "./components/BlueskyProfile";
export * from "./components/BlueskyQuotePost";
+
export * from "./components/GrainGallery";
export * from "./components/LeafletDocument";
export * from "./components/TangledRepo";
export * from "./components/TangledString";
···
// Renderers
export * from "./renderers/BlueskyPostRenderer";
export * from "./renderers/BlueskyProfileRenderer";
+
export * from "./renderers/GrainGalleryRenderer";
export * from "./renderers/LeafletDocumentRenderer";
export * from "./renderers/TangledRepoRenderer";
export * from "./renderers/TangledStringRenderer";
// Types
export * from "./types/bluesky";
+
export * from "./types/grain";
export * from "./types/leaflet";
export * from "./types/tangled";
export * from "./types/theme";
+764
lib/renderers/GrainGalleryRenderer.tsx
···
+
import React from "react";
+
import type { GrainGalleryRecord, GrainPhotoRecord } from "../types/grain";
+
import { useBlob } from "../hooks/useBlob";
+
import { isBlobWithCdn, extractCidFromBlob } from "../utils/blob";
+
+
export interface GrainGalleryPhoto {
+
record: GrainPhotoRecord;
+
did: string;
+
rkey: string;
+
position?: number;
+
}
+
+
export interface GrainGalleryRendererProps {
+
gallery: GrainGalleryRecord;
+
photos: GrainGalleryPhoto[];
+
loading: boolean;
+
error?: Error;
+
authorHandle?: string;
+
authorDisplayName?: string;
+
avatarUrl?: string;
+
}
+
+
export const GrainGalleryRenderer: React.FC<GrainGalleryRendererProps> = ({
+
gallery,
+
photos,
+
loading,
+
error,
+
authorDisplayName,
+
authorHandle,
+
avatarUrl,
+
}) => {
+
const [currentPage, setCurrentPage] = React.useState(0);
+
const [lightboxOpen, setLightboxOpen] = React.useState(false);
+
const [lightboxPhotoIndex, setLightboxPhotoIndex] = React.useState(0);
+
+
const createdDate = new Date(gallery.createdAt);
+
const created = createdDate.toLocaleString(undefined, {
+
dateStyle: "medium",
+
timeStyle: "short",
+
});
+
+
const primaryName = authorDisplayName || authorHandle || "…";
+
+
// Open lightbox
+
const openLightbox = React.useCallback((photoIndex: number) => {
+
setLightboxPhotoIndex(photoIndex);
+
setLightboxOpen(true);
+
}, []);
+
+
// Close lightbox
+
const closeLightbox = React.useCallback(() => {
+
setLightboxOpen(false);
+
}, []);
+
+
// Navigate lightbox
+
const goToNextPhoto = React.useCallback(() => {
+
setLightboxPhotoIndex((prev) => (prev + 1) % sortedPhotos.length);
+
}, [sortedPhotos.length]);
+
+
const goToPrevPhoto = React.useCallback(() => {
+
setLightboxPhotoIndex((prev) => (prev - 1 + sortedPhotos.length) % sortedPhotos.length);
+
}, [sortedPhotos.length]);
+
+
// Keyboard navigation
+
React.useEffect(() => {
+
if (!lightboxOpen) return;
+
+
const handleKeyDown = (e: KeyboardEvent) => {
+
if (e.key === "Escape") closeLightbox();
+
if (e.key === "ArrowLeft") goToPrevPhoto();
+
if (e.key === "ArrowRight") goToNextPhoto();
+
};
+
+
window.addEventListener("keydown", handleKeyDown);
+
return () => window.removeEventListener("keydown", handleKeyDown);
+
}, [lightboxOpen, closeLightbox, goToPrevPhoto, goToNextPhoto]);
+
+
// Memoize sorted photos to prevent re-sorting on every render
+
const sortedPhotos = React.useMemo(
+
() => [...photos].sort((a, b) => (a.position ?? 0) - (b.position ?? 0)),
+
[photos]
+
);
+
+
const isSinglePhoto = sortedPhotos.length === 1;
+
+
// Preload all photos to avoid loading states when paginating
+
usePreloadAllPhotos(sortedPhotos);
+
+
// Reset to first page when photos change
+
React.useEffect(() => {
+
setCurrentPage(0);
+
}, [sortedPhotos.length]);
+
+
// Memoize pagination calculations with intelligent photo count per page
+
const paginationData = React.useMemo(() => {
+
const pages = calculatePages(sortedPhotos);
+
const totalPages = pages.length;
+
const visiblePhotos = pages[currentPage] || [];
+
const hasMultiplePages = totalPages > 1;
+
const layoutPhotos = calculateLayout(visiblePhotos);
+
+
return {
+
pages,
+
totalPages,
+
visiblePhotos,
+
hasMultiplePages,
+
layoutPhotos,
+
};
+
}, [sortedPhotos, currentPage]);
+
+
const { totalPages, hasMultiplePages, layoutPhotos } = paginationData;
+
+
// Memoize navigation handlers to prevent re-creation
+
const goToNextPage = React.useCallback(() => {
+
setCurrentPage((prev) => (prev + 1) % totalPages);
+
}, [totalPages]);
+
+
const goToPrevPage = React.useCallback(() => {
+
setCurrentPage((prev) => (prev - 1 + totalPages) % totalPages);
+
}, [totalPages]);
+
+
if (error) {
+
return (
+
<div style={{ padding: 8, color: "crimson" }}>
+
Failed to load gallery.
+
</div>
+
);
+
}
+
+
if (loading && photos.length === 0) {
+
return <div style={{ padding: 8 }}>Loading gallery…</div>;
+
}
+
+
return (
+
<>
+
{/* Hidden preload elements for all photos */}
+
<div style={{ display: "none" }} aria-hidden>
+
{sortedPhotos.map((photo) => (
+
<PreloadPhoto key={`${photo.did}-${photo.rkey}-preload`} photo={photo} />
+
))}
+
</div>
+
+
{/* Lightbox */}
+
{lightboxOpen && (
+
<Lightbox
+
photo={sortedPhotos[lightboxPhotoIndex]}
+
photoIndex={lightboxPhotoIndex}
+
totalPhotos={sortedPhotos.length}
+
onClose={closeLightbox}
+
onNext={goToNextPhoto}
+
onPrev={goToPrevPhoto}
+
/>
+
)}
+
+
<article style={styles.card}>
+
<header style={styles.header}>
+
{avatarUrl ? (
+
<img src={avatarUrl} alt="avatar" style={styles.avatarImg} />
+
) : (
+
<div style={styles.avatarPlaceholder} aria-hidden />
+
)}
+
<div style={styles.authorInfo}>
+
<strong style={styles.displayName}>{primaryName}</strong>
+
{authorHandle && (
+
<span
+
style={{
+
...styles.handle,
+
color: `var(--atproto-color-text-secondary)`,
+
}}
+
>
+
@{authorHandle}
+
</span>
+
)}
+
</div>
+
</header>
+
+
<div style={styles.galleryInfo}>
+
<h2
+
style={{
+
...styles.title,
+
color: `var(--atproto-color-text)`,
+
}}
+
>
+
{gallery.title}
+
</h2>
+
{gallery.description && (
+
<p
+
style={{
+
...styles.description,
+
color: `var(--atproto-color-text-secondary)`,
+
}}
+
>
+
{gallery.description}
+
</p>
+
)}
+
</div>
+
+
{isSinglePhoto ? (
+
<div style={styles.singlePhotoContainer}>
+
<GalleryPhotoItem key={`${sortedPhotos[0].did}-${sortedPhotos[0].rkey}`} photo={sortedPhotos[0]} isSingle={true} />
+
</div>
+
) : (
+
<div style={styles.carouselContainer}>
+
{hasMultiplePages && currentPage > 0 && (
+
<button
+
onClick={goToPrevPage}
+
onMouseEnter={(e) => (e.currentTarget.style.opacity = "1")}
+
onMouseLeave={(e) => (e.currentTarget.style.opacity = "0.7")}
+
style={{
+
...styles.navButton,
+
...styles.navButtonLeft,
+
color: "white",
+
background: "rgba(0, 0, 0, 0.5)",
+
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
+
}}
+
aria-label="Previous photos"
+
>
+
+
</button>
+
)}
+
<div style={styles.photosGrid}>
+
{layoutPhotos.map((item) => (
+
<GalleryPhotoItem
+
key={`${item.did}-${item.rkey}`}
+
photo={item}
+
isSingle={false}
+
span={item.span}
+
/>
+
))}
+
</div>
+
{hasMultiplePages && currentPage < totalPages - 1 && (
+
<button
+
onClick={goToNextPage}
+
onMouseEnter={(e) => (e.currentTarget.style.opacity = "1")}
+
onMouseLeave={(e) => (e.currentTarget.style.opacity = "0.7")}
+
style={{
+
...styles.navButton,
+
...styles.navButtonRight,
+
color: "white",
+
background: "rgba(0, 0, 0, 0.5)",
+
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
+
}}
+
aria-label="Next photos"
+
>
+
+
</button>
+
)}
+
</div>
+
)}
+
+
<footer style={styles.footer}>
+
<time
+
style={{
+
...styles.time,
+
color: `var(--atproto-color-text-muted)`,
+
}}
+
dateTime={gallery.createdAt}
+
>
+
{created}
+
</time>
+
{hasMultiplePages && !isSinglePhoto && (
+
<div style={styles.paginationDots}>
+
{Array.from({ length: totalPages }, (_, i) => (
+
<button
+
key={i}
+
onClick={() => setCurrentPage(i)}
+
style={{
+
...styles.paginationDot,
+
background: i === currentPage
+
? `var(--atproto-color-text)`
+
: `var(--atproto-color-border)`,
+
}}
+
aria-label={`Go to page ${i + 1}`}
+
aria-current={i === currentPage ? "page" : undefined}
+
/>
+
))}
+
</div>
+
)}
+
</footer>
+
</article>
+
</>
+
);
+
};
+
+
// Component to preload a single photo's blob
+
const PreloadPhoto: React.FC<{ photo: GrainGalleryPhoto }> = ({ photo }) => {
+
const photoBlob = photo.record.photo;
+
const cdnUrl = isBlobWithCdn(photoBlob) ? photoBlob.cdnUrl : undefined;
+
const cid = cdnUrl ? undefined : extractCidFromBlob(photoBlob);
+
+
// Trigger blob loading via the hook
+
useBlob(photo.did, cid);
+
+
// Preload CDN images via Image element
+
React.useEffect(() => {
+
if (cdnUrl) {
+
const img = new Image();
+
img.src = cdnUrl;
+
}
+
}, [cdnUrl]);
+
+
return null;
+
};
+
+
// Hook to preload all photos (CDN-based)
+
const usePreloadAllPhotos = (photos: GrainGalleryPhoto[]) => {
+
React.useEffect(() => {
+
// Preload CDN images
+
photos.forEach((photo) => {
+
const photoBlob = photo.record.photo;
+
const cdnUrl = isBlobWithCdn(photoBlob) ? photoBlob.cdnUrl : undefined;
+
+
if (cdnUrl) {
+
const img = new Image();
+
img.src = cdnUrl;
+
}
+
});
+
}, [photos]);
+
};
+
+
// Calculate pages with intelligent photo count (1, 2, or 3)
+
// Only includes multiple photos when they fit well together
+
const calculatePages = (photos: GrainGalleryPhoto[]): GrainGalleryPhoto[][] => {
+
if (photos.length === 0) return [];
+
if (photos.length === 1) return [[photos[0]]];
+
+
const pages: GrainGalleryPhoto[][] = [];
+
let i = 0;
+
+
while (i < photos.length) {
+
const remaining = photos.length - i;
+
+
// Only one photo left - use it
+
if (remaining === 1) {
+
pages.push([photos[i]]);
+
break;
+
}
+
+
// Check if next 3 photos can fit well together
+
if (remaining >= 3) {
+
const nextThree = photos.slice(i, i + 3);
+
if (canFitThreePhotos(nextThree)) {
+
pages.push(nextThree);
+
i += 3;
+
continue;
+
}
+
}
+
+
// Check if next 2 photos can fit well together
+
if (remaining >= 2) {
+
const nextTwo = photos.slice(i, i + 2);
+
if (canFitTwoPhotos(nextTwo)) {
+
pages.push(nextTwo);
+
i += 2;
+
continue;
+
}
+
}
+
+
// Photos don't fit well together, use 1 per page
+
pages.push([photos[i]]);
+
i += 1;
+
}
+
+
return pages;
+
};
+
+
// Helper functions for aspect ratio classification
+
const isPortrait = (ratio: number) => ratio < 0.8;
+
const isLandscape = (ratio: number) => ratio > 1.2;
+
const isSquarish = (ratio: number) => ratio >= 0.8 && ratio <= 1.2;
+
+
// Determine if 2 photos can fit well together side by side
+
const canFitTwoPhotos = (photos: GrainGalleryPhoto[]): boolean => {
+
if (photos.length !== 2) return false;
+
+
const ratios = photos.map((p) => {
+
const ar = p.record.aspectRatio;
+
return ar ? ar.width / ar.height : 1;
+
});
+
+
const [r1, r2] = ratios;
+
+
// Two portraits side by side don't work well (too narrow)
+
if (isPortrait(r1) && isPortrait(r2)) return false;
+
+
// Portrait + landscape/square creates awkward layout
+
if (isPortrait(r1) && !isPortrait(r2)) return false;
+
if (!isPortrait(r1) && isPortrait(r2)) return false;
+
+
// Two landscape or two squarish photos work well
+
if ((isLandscape(r1) || isSquarish(r1)) && (isLandscape(r2) || isSquarish(r2))) {
+
return true;
+
}
+
+
// Default to not fitting
+
return false;
+
};
+
+
// Determine if 3 photos can fit well together in a layout
+
const canFitThreePhotos = (photos: GrainGalleryPhoto[]): boolean => {
+
if (photos.length !== 3) return false;
+
+
const ratios = photos.map((p) => {
+
const ar = p.record.aspectRatio;
+
return ar ? ar.width / ar.height : 1;
+
});
+
+
const [r1, r2, r3] = ratios;
+
+
// Good pattern: one portrait, two landscape/square
+
if (isPortrait(r1) && !isPortrait(r2) && !isPortrait(r3)) return true;
+
if (isPortrait(r3) && !isPortrait(r1) && !isPortrait(r2)) return true;
+
+
// Good pattern: all similar aspect ratios (all landscape or all squarish)
+
const allLandscape = ratios.every(isLandscape);
+
const allSquarish = ratios.every(isSquarish);
+
if (allLandscape || allSquarish) return true;
+
+
// Three portraits in a row can work
+
const allPortrait = ratios.every(isPortrait);
+
if (allPortrait) return true;
+
+
// Otherwise don't fit 3 together
+
return false;
+
};
+
+
// Layout calculator for intelligent photo grid arrangement
+
const calculateLayout = (photos: GrainGalleryPhoto[]) => {
+
if (photos.length === 0) return [];
+
if (photos.length === 1) {
+
return [{ ...photos[0], span: { row: 2, col: 2 } }];
+
}
+
+
const photosWithRatios = photos.map((photo) => {
+
const ratio = photo.record.aspectRatio
+
? photo.record.aspectRatio.width / photo.record.aspectRatio.height
+
: 1;
+
return {
+
...photo,
+
ratio,
+
isPortrait: isPortrait(ratio),
+
isLandscape: isLandscape(ratio)
+
};
+
});
+
+
// For 2 photos: side by side
+
if (photos.length === 2) {
+
return photosWithRatios.map((p) => ({ ...p, span: { row: 2, col: 1 } }));
+
}
+
+
// For 3 photos: try to create a balanced layout
+
if (photos.length === 3) {
+
const [p1, p2, p3] = photosWithRatios;
+
+
// Pattern 1: One tall on left, two stacked on right
+
if (p1.isPortrait && !p2.isPortrait && !p3.isPortrait) {
+
return [
+
{ ...p1, span: { row: 2, col: 1 } },
+
{ ...p2, span: { row: 1, col: 1 } },
+
{ ...p3, span: { row: 1, col: 1 } },
+
];
+
}
+
+
// Pattern 2: Two stacked on left, one tall on right
+
if (!p1.isPortrait && !p2.isPortrait && p3.isPortrait) {
+
return [
+
{ ...p1, span: { row: 1, col: 1 } },
+
{ ...p2, span: { row: 1, col: 1 } },
+
{ ...p3, span: { row: 2, col: 1 } },
+
];
+
}
+
+
// Pattern 3: All in a row
+
const allPortrait = photosWithRatios.every((p) => p.isPortrait);
+
if (allPortrait) {
+
// All portraits: display in a row with smaller cells
+
return photosWithRatios.map((p) => ({ ...p, span: { row: 1, col: 1 } }));
+
}
+
+
// Default: All three in a row
+
return photosWithRatios.map((p) => ({ ...p, span: { row: 1, col: 1 } }));
+
}
+
+
return photosWithRatios.map((p) => ({ ...p, span: { row: 1, col: 1 } }));
+
};
+
+
const GalleryPhotoItem: React.FC<{
+
photo: GrainGalleryPhoto;
+
isSingle: boolean;
+
span?: { row: number; col: number };
+
}> = ({ photo, isSingle, span }) => {
+
const [showAltText, setShowAltText] = React.useState(false);
+
const photoBlob = photo.record.photo;
+
const cdnUrl = isBlobWithCdn(photoBlob) ? photoBlob.cdnUrl : undefined;
+
const cid = cdnUrl ? undefined : extractCidFromBlob(photoBlob);
+
const { url: urlFromBlob, loading: photoLoading, error: photoError } = useBlob(photo.did, cid);
+
const url = cdnUrl || urlFromBlob;
+
const alt = photo.record.alt?.trim() || "grain.social photo";
+
const hasAlt = photo.record.alt && photo.record.alt.trim().length > 0;
+
+
const aspect =
+
photo.record.aspectRatio && photo.record.aspectRatio.height > 0
+
? `${photo.record.aspectRatio.width} / ${photo.record.aspectRatio.height}`
+
: undefined;
+
+
const gridItemStyle = span
+
? {
+
gridRow: `span ${span.row}`,
+
gridColumn: `span ${span.col}`,
+
}
+
: {};
+
+
return (
+
<figure style={{ ...(isSingle ? styles.singlePhotoItem : styles.photoItem), ...gridItemStyle }}>
+
<div
+
style={{
+
...(isSingle ? styles.singlePhotoMedia : styles.photoContainer),
+
background: `var(--atproto-color-image-bg)`,
+
// Only apply aspect ratio for single photos; grid photos fill their cells
+
...(isSingle && aspect ? { aspectRatio: aspect } : {}),
+
}}
+
>
+
{url ? (
+
<img src={url} alt={alt} style={isSingle ? styles.photo : styles.photoGrid} />
+
) : (
+
<div
+
style={{
+
...styles.placeholder,
+
color: `var(--atproto-color-text-muted)`,
+
}}
+
>
+
{photoLoading
+
? "Loading…"
+
: photoError
+
? "Failed to load"
+
: "Unavailable"}
+
</div>
+
)}
+
{hasAlt && (
+
<button
+
onClick={() => setShowAltText(!showAltText)}
+
style={{
+
...styles.altBadge,
+
background: showAltText
+
? `var(--atproto-color-text)`
+
: `var(--atproto-color-bg-secondary)`,
+
color: showAltText
+
? `var(--atproto-color-bg)`
+
: `var(--atproto-color-text)`,
+
}}
+
title="Toggle alt text"
+
aria-label="Toggle alt text"
+
>
+
ALT
+
</button>
+
)}
+
</div>
+
{hasAlt && showAltText && (
+
<figcaption
+
style={{
+
...styles.caption,
+
color: `var(--atproto-color-text-secondary)`,
+
}}
+
>
+
{photo.record.alt}
+
</figcaption>
+
)}
+
</figure>
+
);
+
};
+
+
const styles: Record<string, React.CSSProperties> = {
+
card: {
+
borderRadius: 12,
+
border: `1px solid var(--atproto-color-border)`,
+
background: `var(--atproto-color-bg)`,
+
color: `var(--atproto-color-text)`,
+
fontFamily: "system-ui, sans-serif",
+
display: "flex",
+
flexDirection: "column",
+
maxWidth: 600,
+
transition:
+
"background-color 180ms ease, border-color 180ms ease, color 180ms ease",
+
overflow: "hidden",
+
},
+
header: {
+
display: "flex",
+
alignItems: "center",
+
gap: 12,
+
padding: 12,
+
paddingBottom: 0,
+
},
+
avatarPlaceholder: {
+
width: 32,
+
height: 32,
+
borderRadius: "50%",
+
background: `var(--atproto-color-border)`,
+
},
+
avatarImg: {
+
width: 32,
+
height: 32,
+
borderRadius: "50%",
+
objectFit: "cover",
+
},
+
authorInfo: {
+
display: "flex",
+
flexDirection: "column",
+
gap: 2,
+
},
+
displayName: {
+
fontSize: 14,
+
fontWeight: 600,
+
},
+
handle: {
+
fontSize: 12,
+
},
+
galleryInfo: {
+
padding: 12,
+
paddingBottom: 8,
+
},
+
title: {
+
margin: 0,
+
fontSize: 18,
+
fontWeight: 600,
+
marginBottom: 4,
+
},
+
description: {
+
margin: 0,
+
fontSize: 14,
+
lineHeight: 1.4,
+
whiteSpace: "pre-wrap",
+
},
+
singlePhotoContainer: {
+
padding: 0,
+
},
+
carouselContainer: {
+
position: "relative",
+
padding: 4,
+
},
+
photosGrid: {
+
display: "grid",
+
gridTemplateColumns: "repeat(2, 1fr)",
+
gridTemplateRows: "repeat(2, 1fr)",
+
gap: 4,
+
minHeight: 400,
+
},
+
navButton: {
+
position: "absolute",
+
top: "50%",
+
transform: "translateY(-50%)",
+
width: 28,
+
height: 28,
+
border: "none",
+
borderRadius: "50%",
+
fontSize: 18,
+
fontWeight: "600",
+
cursor: "pointer",
+
display: "flex",
+
alignItems: "center",
+
justifyContent: "center",
+
zIndex: 10,
+
transition: "opacity 150ms ease",
+
userSelect: "none",
+
opacity: 0.7,
+
},
+
navButtonLeft: {
+
left: 8,
+
},
+
navButtonRight: {
+
right: 8,
+
},
+
photoItem: {
+
margin: 0,
+
display: "flex",
+
flexDirection: "column",
+
gap: 4,
+
},
+
singlePhotoItem: {
+
margin: 0,
+
display: "flex",
+
flexDirection: "column",
+
gap: 8,
+
},
+
photoContainer: {
+
position: "relative",
+
width: "100%",
+
height: "100%",
+
overflow: "hidden",
+
borderRadius: 4,
+
},
+
singlePhotoMedia: {
+
position: "relative",
+
width: "100%",
+
overflow: "hidden",
+
borderRadius: 0,
+
},
+
photo: {
+
width: "100%",
+
height: "100%",
+
objectFit: "cover",
+
display: "block",
+
},
+
photoGrid: {
+
width: "100%",
+
height: "100%",
+
objectFit: "cover",
+
display: "block",
+
},
+
placeholder: {
+
display: "flex",
+
alignItems: "center",
+
justifyContent: "center",
+
width: "100%",
+
height: "100%",
+
minHeight: 100,
+
fontSize: 12,
+
},
+
caption: {
+
fontSize: 12,
+
lineHeight: 1.3,
+
padding: "0 12px 8px",
+
},
+
altBadge: {
+
position: "absolute",
+
bottom: 8,
+
right: 8,
+
padding: "4px 8px",
+
fontSize: 10,
+
fontWeight: 600,
+
letterSpacing: "0.5px",
+
border: "none",
+
borderRadius: 4,
+
cursor: "pointer",
+
transition: "background 150ms ease, color 150ms ease",
+
fontFamily: "system-ui, sans-serif",
+
},
+
footer: {
+
padding: 12,
+
paddingTop: 8,
+
display: "flex",
+
justifyContent: "space-between",
+
alignItems: "center",
+
},
+
time: {
+
fontSize: 11,
+
},
+
paginationDots: {
+
display: "flex",
+
gap: 6,
+
alignItems: "center",
+
},
+
paginationDot: {
+
width: 6,
+
height: 6,
+
borderRadius: "50%",
+
border: "none",
+
padding: 0,
+
cursor: "pointer",
+
transition: "background 200ms ease, transform 150ms ease",
+
flexShrink: 0,
+
},
+
};
+
+
export default GrainGalleryRenderer;
+95
lib/types/grain.ts
···
+
/**
+
* Type definitions for grain.social records
+
* Uses standard atcute blob types for compatibility
+
*/
+
import type { Blob } from "@atcute/lexicons/interfaces";
+
import type { BlobWithCdn } from "../hooks/useBlueskyAppview";
+
+
/**
+
* grain.social gallery record
+
* A container for a collection of photos
+
*/
+
export interface GrainGalleryRecord {
+
/**
+
* Record type identifier
+
*/
+
$type: "social.grain.gallery";
+
/**
+
* Gallery title
+
*/
+
title: string;
+
/**
+
* Gallery description
+
*/
+
description?: string;
+
/**
+
* Self-label values (content warnings)
+
*/
+
labels?: {
+
$type: "com.atproto.label.defs#selfLabels";
+
values: Array<{ val: string }>;
+
};
+
/**
+
* Timestamp when the gallery was created
+
*/
+
createdAt: string;
+
}
+
+
/**
+
* grain.social gallery item record
+
* Links a photo to a gallery
+
*/
+
export interface GrainGalleryItemRecord {
+
/**
+
* Record type identifier
+
*/
+
$type: "social.grain.gallery.item";
+
/**
+
* AT URI of the photo (social.grain.photo)
+
*/
+
item: string;
+
/**
+
* AT URI of the gallery this item belongs to
+
*/
+
gallery: string;
+
/**
+
* Position/order within the gallery
+
*/
+
position?: number;
+
/**
+
* Timestamp when the item was added to the gallery
+
*/
+
createdAt: string;
+
}
+
+
/**
+
* grain.social photo record
+
* Compatible with records from @atcute clients
+
*/
+
export interface GrainPhotoRecord {
+
/**
+
* Record type identifier
+
*/
+
$type: "social.grain.photo";
+
/**
+
* Alt text description of the image (required for accessibility)
+
*/
+
alt: string;
+
/**
+
* Photo blob reference - uses standard AT Proto blob format
+
* Supports any image/* mime type
+
* May include cdnUrl when fetched from appview
+
*/
+
photo: Blob<`image/${string}`> | BlobWithCdn;
+
/**
+
* Timestamp when the photo was created
+
*/
+
createdAt?: string;
+
/**
+
* Aspect ratio of the photo
+
*/
+
aspectRatio?: {
+
width: number;
+
height: number;
+
};
+
}
+19
src/App.tsx
···
} from "../lib/components/BlueskyPost";
import { BlueskyPostList } from "../lib/components/BlueskyPostList";
import { BlueskyQuotePost } from "../lib/components/BlueskyQuotePost";
+
import { GrainGallery } from "../lib/components/GrainGallery";
import { useDidResolution } from "../lib/hooks/useDidResolution";
import { useLatestRecord } from "../lib/hooks/useLatestRecord";
import type { FeedPostRecord } from "../lib/types/bluesky";
···
<section style={panelStyle}>
<h3 style={sectionHeaderStyle}>Recent Posts</h3>
<BlueskyPostList did={did} />
+
</section>
+
<section style={panelStyle}>
+
<h3 style={sectionHeaderStyle}>
+
grain.social Gallery Demo
+
</h3>
+
<p
+
style={{
+
fontSize: 12,
+
color: `var(--demo-text-secondary)`,
+
margin: "0 0 8px",
+
}}
+
>
+
Instagram-style photo gallery from grain.social
+
</p>
+
<GrainGallery
+
did="kat.meangirls.online"
+
rkey="3m2e2qikseq2f"
+
/>
</section>
</div>
<div style={columnStackStyle}>