···11+# atproto-ui
22+33+atproto-ui is a component library and set of hooks for rendering records from the AT Protocol (Bluesky, Leaflet, and friends) in React applications. It handles DID resolution, PDS endpoint discovery, and record fetching so you can focus on UI.
44+55+## Features
66+77+- Drop-in components for common record types (`BlueskyPost`, `BlueskyProfile`, `TangledString`, etc.).
88+- Hooks and helpers for composing your own renderers for your own applications, (PRs welcome!)
99+- Built on the lightweight [`@atcute/*`](https://github.com/atcute) clients.
1010+1111+## Installation
1212+1313+```bash
1414+npm install atproto-ui
1515+```
1616+1717+## Quick start
1818+1919+1. Wrap your app (once) with the `AtProtoProvider`.
2020+2. Drop any of the ready-made components inside that provider.
2121+3. Use the hooks to prefetch handles, blobs, or latest records when you want to control the render flow yourself.
2222+2323+```tsx
2424+import { AtProtoProvider, BlueskyPost } from 'atproto-ui';
2525+2626+export function App() {
2727+ return (
2828+ <AtProtoProvider>
2929+ <BlueskyPost did="did:plc:example" rkey="3k2aexample" />
3030+ {/* you can use handles in the components as well. */}
3131+ <LeafletDocument did="nekomimi.pet" rkey="3m2seagm2222c" />
3232+ </AtProtoProvider>
3333+ );
3434+}
3535+```
3636+3737+### Available building blocks
3838+3939+| Component / Hook | What it does |
4040+| --- | --- |
4141+| `AtProtoProvider` | Configures PLC directory (defaults to `https://plc.directory`) and shares protocol clients via React context. |
4242+| `BlueskyProfile` | Renders a profile card for a DID/handle. Accepts `fallback`, `loadingIndicator`, `renderer`, and `colorScheme`. |
4343+| `BlueskyPost` / `BlueskyQuotePost` | Shows a single Bluesky post, with quotation support, custom renderer overrides, and the same loading/fallback knobs. |
4444+| `BlueskyPostList` | Lists the latest posts with built-in pagination (defaults: 5 per page, pagination controls on). |
4545+| `TangledString` | Renders a Tangled string (gist-like record) with optional renderer overrides. |
4646+| `LeafletDocument` | Displays long-form Leaflet documents with blocks, theme support, and renderer overrides. |
4747+| `useDidResolution`, `useLatestRecord`, `usePaginatedRecords`, … | Hook-level access to records if you want to own the markup or prefill components. |
4848+4949+All components accept a `colorScheme` of `'light' | 'dark' | 'system'` so they can blend into your design. They also accept `fallback` and `loadingIndicator` props to control what renders before or during network work, and most expose a `renderer` override when you need total control of the final markup.
5050+5151+### Prefill components with the latest record
5252+5353+`useLatestRecord` gives you the most recent record for any collection along with its `rkey`. You can use that key to pre-populate components like `BlueskyPost`, `LeafletDocument`, or `TangledString`.
5454+5555+```tsx
5656+import { useLatestRecord, BlueskyPost } from 'atproto-ui';
5757+import type { FeedPostRecord } from 'atproto-ui';
5858+5959+const LatestBlueskyPost: React.FC<{ did: string }> = ({ did }) => {
6060+ const { rkey, loading, error, empty } = useLatestRecord<FeedPostRecord>(did, 'app.bsky.feed.post');
6161+6262+ if (loading) return <p>Fetching latest post…</p>;
6363+ if (error) return <p>Could not load: {error.message}</p>;
6464+ if (empty || !rkey) return <p>No posts yet.</p>;
6565+6666+ return (
6767+ <BlueskyPost
6868+ did={did}
6969+ rkey={rkey}
7070+ colorScheme="system"
7171+ />
7272+ );
7373+};
7474+```
7575+7676+The same pattern works for other components: swap the collection NSID and the component you render once you have an `rkey`.
7777+7878+```tsx
7979+const LatestLeafletDocument: React.FC<{ did: string }> = ({ did }) => {
8080+ const { rkey } = useLatestRecord(did, 'pub.leaflet.document');
8181+ return rkey ? <LeafletDocument did={did} rkey={rkey} colorScheme="light" /> : null;
8282+};
8383+```
8484+8585+## Compose your own component
8686+8787+The helpers let you stitch together custom experiences without reimplementing protocol plumbing. The example below pulls a creator’s latest post and renders a minimal summary:
8888+8989+```tsx
9090+import { useLatestRecord, useColorScheme, AtProtoRecord } from 'atproto-ui';
9191+import type { FeedPostRecord } from 'atproto-ui';
9292+9393+const LatestPostSummary: React.FC<{ did: string }> = ({ did }) => {
9494+ const scheme = useColorScheme('system');
9595+ const { rkey, loading, error } = useLatestRecord<FeedPostRecord>(did, 'app.bsky.feed.post');
9696+9797+ if (loading) return <span>Loading…</span>;
9898+ if (error || !rkey) return <span>No post yet.</span>;
9999+100100+ return (
101101+ <AtProtoRecord<FeedPostRecord>
102102+ did={did}
103103+ collection="app.bsky.feed.post"
104104+ rkey={rkey}
105105+ renderer={({ record }) => (
106106+ <article data-color-scheme={scheme}>
107107+ <strong>{record?.text ?? 'Empty post'}</strong>
108108+ </article>
109109+ )}
110110+ />
111111+ );
112112+};
113113+```
114114+115115+There is a [demo](https://wisp.place/s/ana.pds.nkp.pet/ATComponents) where you can see the components in live action.
116116+117117+## Running the demo locally
118118+119119+```bash
120120+npm install
121121+npm run dev
122122+```
123123+124124+Then open the printed Vite URL and try entering a Bluesky handle to see the components in action.
125125+126126+## Next steps
127127+128128+- Expand renderer coverage (e.g., Grain.social photos).
129129+- Expand documentation with TypeScript API references and theming guidelines.
130130+131131+Contributions and ideas are welcome—feel free to open an issue or PR!
···11+import { useEffect, useState } from 'react';
22+import { usePdsEndpoint } from './usePdsEndpoint';
33+import { createAtprotoClient } from '../utils/atproto-client';
44+55+/**
66+ * Identifier trio required to address an AT Protocol record.
77+ */
88+export interface AtProtoRecordKey {
99+ /** Repository DID (or handle prior to resolution) containing the record. */
1010+ did?: string;
1111+ /** NSID collection in which the record resides. */
1212+ collection: string;
1313+ /** Record key string uniquely identifying the record within the collection. */
1414+ rkey: string;
1515+}
1616+1717+/**
1818+ * Loading state returned by {@link useAtProtoRecord}.
1919+ */
2020+export interface AtProtoRecordState<T = unknown> {
2121+ /** Resolved record value when fetch succeeds. */
2222+ record?: T;
2323+ /** Error thrown while loading, if any. */
2424+ error?: Error;
2525+ /** Indicates whether the hook is in a loading state. */
2626+ loading: boolean;
2727+}
2828+2929+/**
3030+ * React hook that fetches a single AT Protocol record and tracks loading/error state.
3131+ *
3232+ * @param did - DID (or handle before resolution) that owns the record.
3333+ * @param collection - NSID collection from which to fetch the record.
3434+ * @param rkey - Record key identifying the record within the collection.
3535+ * @returns {AtProtoRecordState<T>} Object containing the resolved record, any error, and a loading flag.
3636+ */
3737+export function useAtProtoRecord<T = unknown>({ did, collection, rkey }: AtProtoRecordKey): AtProtoRecordState<T> {
3838+ const { endpoint, error: endpointError } = usePdsEndpoint(did);
3939+ const [state, setState] = useState<AtProtoRecordState<T>>({ loading: !!did });
4040+4141+ useEffect(() => {
4242+ let cancelled = false;
4343+ async function load() {
4444+ if (!did || !endpoint) return;
4545+ setState(s => ({ ...s, loading: true }));
4646+ try {
4747+ const { rpc } = await createAtprotoClient({ service: endpoint });
4848+ // Type of getRecord lexicon not available in generic Client here, so cast through unknown.
4949+ const res = await (rpc as unknown as { get: (nsid: string, opts: { params: { repo: string; collection: string; rkey: string } }) => Promise<{ ok: boolean; data: { value: T } }> }).get('com.atproto.repo.getRecord', {
5050+ params: { repo: did, collection, rkey }
5151+ });
5252+ if (!res.ok) throw new Error('Failed to load record');
5353+ const record = (res.data as { value: T }).value;
5454+ if (!cancelled) setState({ record, loading: false });
5555+ } catch (e) {
5656+ if (!cancelled) setState({ error: e as Error, loading: false });
5757+ }
5858+ }
5959+ load();
6060+ return () => { cancelled = true; };
6161+ }, [did, endpoint, collection, rkey]);
6262+6363+ if (endpointError && !state.error) return { ...state, error: endpointError };
6464+ return state;
6565+}
+51
lib/hooks/useBlob.ts
···11+import { useEffect, useState } from 'react';
22+import { usePdsEndpoint } from './usePdsEndpoint';
33+44+/**
55+ * Status returned by {@link useBlob} containing blob URL and metadata flags.
66+ */
77+export interface UseBlobState {
88+ /** Object URL pointing to the fetched blob, when available. */
99+ url?: string;
1010+ /** Indicates whether a fetch is in progress. */
1111+ loading: boolean;
1212+ /** Error encountered while fetching the blob. */
1313+ error?: Error;
1414+}
1515+1616+/**
1717+ * Fetches a blob from the DID's PDS, exposes it as an object URL, and cleans up on unmount.
1818+ *
1919+ * @param did - DID whose PDS hosts the blob.
2020+ * @param cid - Content identifier for the desired blob.
2121+ * @returns {UseBlobState} Object containing the object URL, loading flag, and any error.
2222+ */
2323+export function useBlob(did: string | undefined, cid: string | undefined): UseBlobState {
2424+ const { endpoint } = usePdsEndpoint(did);
2525+ const [state, setState] = useState<UseBlobState>({ loading: !!(did && cid) });
2626+2727+ useEffect(() => {
2828+ let cancelled = false;
2929+ let objectUrl: string | undefined;
3030+ async function run() {
3131+ if (!did || !cid || !endpoint) { setState({ loading: false }); return; }
3232+ setState(s => ({ ...s, loading: true }));
3333+ try {
3434+ const res = await fetch(`${endpoint}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`);
3535+ if (!res.ok) throw new Error('Blob fetch failed');
3636+ const blob = await res.blob();
3737+ objectUrl = URL.createObjectURL(blob);
3838+ if (!cancelled) setState({ url: objectUrl, loading: false });
3939+ } catch (e) {
4040+ if (!cancelled) setState({ error: e as Error, loading: false });
4141+ }
4242+ }
4343+ run();
4444+ return () => {
4545+ cancelled = true;
4646+ if (objectUrl) URL.revokeObjectURL(objectUrl);
4747+ };
4848+ }, [did, cid, endpoint]);
4949+5050+ return state;
5151+}
+61
lib/hooks/useBlueskyProfile.ts
···11+import { useEffect, useState } from 'react';
22+import { usePdsEndpoint } from './usePdsEndpoint';
33+import { createAtprotoClient } from '../utils/atproto-client';
44+55+/**
66+ * Minimal profile fields returned by the Bluesky actor profile endpoint.
77+ */
88+export interface BlueskyProfileData {
99+ /** Actor DID. */
1010+ did: string;
1111+ /** Actor handle. */
1212+ handle: string;
1313+ /** Display name configured by the actor. */
1414+ displayName?: string;
1515+ /** Profile description/bio. */
1616+ description?: string;
1717+ /** Avatar blob (CID reference). */
1818+ avatar?: string;
1919+ /** Banner image blob (CID reference). */
2020+ banner?: string;
2121+ /** Creation timestamp for the profile. */
2222+ createdAt?: string;
2323+}
2424+2525+/**
2626+ * Fetches a Bluesky actor profile for a DID and exposes loading/error state.
2727+ *
2828+ * @param did - Actor DID whose profile should be retrieved.
2929+ * @returns {{ data: BlueskyProfileData | undefined; loading: boolean; error: Error | undefined }} Object exposing the profile payload, loading flag, and any error.
3030+ */
3131+export function useBlueskyProfile(did: string | undefined) {
3232+ const { endpoint } = usePdsEndpoint(did);
3333+ const [data, setData] = useState<BlueskyProfileData | undefined>();
3434+ const [loading, setLoading] = useState<boolean>(!!did);
3535+ const [error, setError] = useState<Error | undefined>();
3636+3737+ useEffect(() => {
3838+ let cancelled = false;
3939+ async function run() {
4040+ if (!did || !endpoint) return;
4141+ setLoading(true);
4242+ try {
4343+ const { rpc } = await createAtprotoClient({ service: endpoint });
4444+ const client = rpc as unknown as {
4545+ get: (nsid: string, options: { params: { actor: string } }) => Promise<{ ok: boolean; data: unknown }>;
4646+ };
4747+ const res = await client.get('app.bsky.actor.getProfile', { params: { actor: did } });
4848+ if (!res.ok) throw new Error('Profile request failed');
4949+ if (!cancelled) setData(res.data as BlueskyProfileData);
5050+ } catch (e) {
5151+ if (!cancelled) setError(e as Error);
5252+ } finally {
5353+ if (!cancelled) setLoading(false);
5454+ }
5555+ }
5656+ run();
5757+ return () => { cancelled = true; };
5858+ }, [did, endpoint]);
5959+6060+ return { data, loading, error };
6161+}
+56
lib/hooks/useColorScheme.ts
···11+import { useEffect, useState } from 'react';
22+33+/**
44+ * Possible user-facing color scheme preferences.
55+ */
66+export type ColorSchemePreference = 'light' | 'dark' | 'system';
77+88+const MEDIA_QUERY = '(prefers-color-scheme: dark)';
99+1010+/**
1111+ * Resolves a persisted preference into an explicit light/dark value.
1212+ *
1313+ * @param pref - Stored preference value (`light`, `dark`, or `system`).
1414+ * @returns Explicit light/dark scheme suitable for rendering.
1515+ */
1616+function resolveScheme(pref: ColorSchemePreference): 'light' | 'dark' {
1717+ if (pref === 'light' || pref === 'dark') return pref;
1818+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
1919+ return 'light';
2020+ }
2121+ return window.matchMedia(MEDIA_QUERY).matches ? 'dark' : 'light';
2222+}
2323+2424+/**
2525+ * React hook that returns the effective light/dark scheme, respecting system preferences.
2626+ *
2727+ * @param preference - User preference; defaults to following the OS setting.
2828+ * @returns {'light' | 'dark'} Explicit scheme that should be used for rendering.
2929+ */
3030+export function useColorScheme(preference: ColorSchemePreference = 'system'): 'light' | 'dark' {
3131+ const [scheme, setScheme] = useState<'light' | 'dark'>(() => resolveScheme(preference));
3232+3333+ useEffect(() => {
3434+ if (preference === 'light' || preference === 'dark') {
3535+ setScheme(preference);
3636+ return;
3737+ }
3838+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
3939+ setScheme('light');
4040+ return;
4141+ }
4242+ const media = window.matchMedia(MEDIA_QUERY);
4343+ const update = (event: MediaQueryListEvent | MediaQueryList) => {
4444+ setScheme(event.matches ? 'dark' : 'light');
4545+ };
4646+ update(media);
4747+ if (typeof media.addEventListener === 'function') {
4848+ media.addEventListener('change', update);
4949+ return () => media.removeEventListener('change', update);
5050+ }
5151+ media.addListener(update);
5252+ return () => media.removeListener(update);
5353+ }, [preference]);
5454+5555+ return scheme;
5656+}
+41
lib/hooks/useDidHandle.ts
···11+import { useEffect, useState } from 'react';
22+import { useAtProto } from '../providers/AtProtoProvider';
33+44+/**
55+ * Resolves a DID document and extracts the associated Bluesky handle from `alsoKnownAs`.
66+ *
77+ * @param did - DID to resolve.
88+ * @returns {{ handle: string | undefined; loading: boolean }} Object containing the derived handle and a loading flag.
99+ */
1010+export function useDidHandle(did: string | undefined) {
1111+ const { resolver } = useAtProto();
1212+ const [handle, setHandle] = useState<string | undefined>();
1313+ const [loading, setLoading] = useState<boolean>(!!did);
1414+1515+ useEffect(() => {
1616+ let cancelled = false;
1717+ if (!did) {
1818+ setHandle(undefined);
1919+ setLoading(false);
2020+ return () => { cancelled = true; };
2121+ }
2222+ setLoading(true);
2323+ const input = did;
2424+ async function run() {
2525+ try {
2626+ const doc = await resolver.resolveDidDoc(input);
2727+ const aka = doc.alsoKnownAs?.find(a => a.startsWith('at://'));
2828+ const extracted = aka ? aka.replace('at://', '') : undefined;
2929+ if (!cancelled) setHandle(extracted);
3030+ } catch {
3131+ if (!cancelled) setHandle(undefined);
3232+ } finally {
3333+ if (!cancelled) setLoading(false);
3434+ }
3535+ }
3636+ run();
3737+ return () => { cancelled = true; };
3838+ }, [did, resolver]);
3939+4040+ return { handle, loading };
4141+}
+46
lib/hooks/useDidResolution.ts
···11+import { useEffect, useState } from 'react';
22+import { useAtProto } from '../providers/AtProtoProvider';
33+44+/**
55+ * Resolves a handle to its DID, or returns the DID immediately when provided.
66+ *
77+ * @param handleOrDid - Bluesky handle or DID string.
88+ * @returns {{ did: string | undefined; error: Error | undefined; loading: boolean }} Object containing the resolved DID, error (if any), and loading state.
99+ */
1010+export function useDidResolution(handleOrDid: string | undefined) {
1111+ const { resolver } = useAtProto();
1212+ const [did, setDid] = useState<string | undefined>();
1313+ const [error, setError] = useState<Error | undefined>();
1414+ const [loading, setLoading] = useState(false);
1515+1616+ useEffect(() => {
1717+ let cancelled = false;
1818+ if (!handleOrDid) {
1919+ setDid(undefined);
2020+ setLoading(false);
2121+ return () => { cancelled = true; };
2222+ }
2323+ setLoading(true);
2424+ setError(undefined);
2525+ const input = handleOrDid;
2626+ async function run() {
2727+ try {
2828+ if (input.startsWith('did:')) {
2929+ if (!cancelled) setDid(input);
3030+ } else {
3131+ const resolved = await resolver.resolveHandle(input);
3232+ if (!cancelled) setDid(resolved);
3333+ }
3434+ } catch (e) {
3535+ if (!cancelled) setError(e as Error);
3636+ if (!cancelled) setDid(undefined);
3737+ } finally {
3838+ if (!cancelled) setLoading(false);
3939+ }
4040+ }
4141+ run();
4242+ return () => { cancelled = true; };
4343+ }, [handleOrDid, resolver]);
4444+4545+ return { did, error, loading };
4646+}
+67
lib/hooks/useLatestRecord.ts
···11+import { usePdsEndpoint } from './usePdsEndpoint';
22+import { createAtprotoClient } from '../utils/atproto-client';
33+import { useEffect, useState } from 'react';
44+55+/**
66+ * Shape of the state returned by {@link useLatestRecord}.
77+ */
88+export interface LatestRecordState<T = unknown> {
99+ /** Latest record value if one exists. */
1010+ record?: T;
1111+ /** Record key for the fetched record, when derivable. */
1212+ rkey?: string;
1313+ /** Error encountered while fetching. */
1414+ error?: Error;
1515+ /** Indicates whether a fetch is in progress. */
1616+ loading: boolean;
1717+ /** `true` when the collection has zero records. */
1818+ empty: boolean;
1919+}
2020+2121+/**
2222+ * Fetches the most recent record from a collection using `listRecords(limit=1)`.
2323+ *
2424+ * @param did - DID that owns the collection.
2525+ * @param collection - NSID of the collection to query.
2626+ * @returns {LatestRecordState<T>} Object reporting the latest record value, derived rkey, loading status, emptiness, and any error.
2727+ */
2828+export function useLatestRecord<T = unknown>(did: string | undefined, collection: string): LatestRecordState<T> {
2929+ const { endpoint, error: endpointError } = usePdsEndpoint(did);
3030+ const [state, setState] = useState<LatestRecordState<T>>({ loading: !!did, empty: false });
3131+ // simple one-shot fetch; no refresh logic
3232+3333+ useEffect(() => {
3434+ let cancelled = false;
3535+ async function run() {
3636+ if (!did || !endpoint) return;
3737+ setState(s => ({ ...s, loading: true }));
3838+ try {
3939+ const { rpc } = await createAtprotoClient({ service: endpoint });
4040+ const res = await (rpc as unknown as { get: (nsid: string, opts: { params: Record<string, string | number | boolean> }) => Promise<{ ok: boolean; data: { records: Array<{ uri: string; rkey?: string; value: T }> } }> }).get('com.atproto.repo.listRecords', {
4141+ params: { repo: did, collection, limit: 1, reverse: false }
4242+ });
4343+ if (!res.ok) throw new Error('Failed to list records');
4444+ const list = res.data.records;
4545+ if (list.length === 0) {
4646+ if (!cancelled) setState({ loading: false, empty: true });
4747+ return;
4848+ }
4949+ const first = list[0];
5050+ // derive rkey if not present
5151+ let rkey = first.rkey;
5252+ if (!rkey && first.uri) {
5353+ const parts = first.uri.split('/');
5454+ rkey = parts[parts.length - 1];
5555+ }
5656+ if (!cancelled) setState({ record: first.value, rkey, loading: false, empty: false });
5757+ } catch (e) {
5858+ if (!cancelled) setState({ error: e as Error, loading: false, empty: false });
5959+ }
6060+ }
6161+ run();
6262+ return () => { cancelled = true; };
6363+ }, [did, endpoint, collection]);
6464+6565+ if (endpointError && !state.error) return { ...state, error: endpointError };
6666+ return state;
6767+}
+186
lib/hooks/usePaginatedRecords.ts
···11+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
22+import { usePdsEndpoint } from './usePdsEndpoint';
33+import { createAtprotoClient } from '../utils/atproto-client';
44+55+/**
66+ * Record envelope returned by paginated AT Protocol queries.
77+ */
88+export interface PaginatedRecord<T> {
99+ /** Fully qualified AT URI for the record. */
1010+ uri: string;
1111+ /** Record key extracted from the URI or provided by the API. */
1212+ rkey: string;
1313+ /** Raw record value. */
1414+ value: T;
1515+}
1616+1717+interface PageData<T> {
1818+ records: PaginatedRecord<T>[];
1919+ cursor?: string;
2020+}
2121+2222+/**
2323+ * Options accepted by {@link usePaginatedRecords}.
2424+ */
2525+export interface UsePaginatedRecordsOptions {
2626+ /** DID whose repository should be queried. */
2727+ did?: string;
2828+ /** NSID collection containing the target records. */
2929+ collection: string;
3030+ /** Maximum page size to request; defaults to `5`. */
3131+ limit?: number;
3232+}
3333+3434+/**
3535+ * Result returned from {@link usePaginatedRecords} describing records and pagination state.
3636+ */
3737+export interface UsePaginatedRecordsResult<T> {
3838+ /** Records for the active page. */
3939+ records: PaginatedRecord<T>[];
4040+ /** Indicates whether a page load is in progress. */
4141+ loading: boolean;
4242+ /** Error produced during the latest fetch, if any. */
4343+ error?: Error;
4444+ /** `true` when another page can be fetched forward. */
4545+ hasNext: boolean;
4646+ /** `true` when a previous page exists in memory. */
4747+ hasPrev: boolean;
4848+ /** Requests the next page (if available). */
4949+ loadNext: () => void;
5050+ /** Returns to the previous page when possible. */
5151+ loadPrev: () => void;
5252+ /** Index of the currently displayed page. */
5353+ pageIndex: number;
5454+ /** Number of pages fetched so far (or inferred total when known). */
5555+ pagesCount: number;
5656+}
5757+5858+/**
5959+ * React hook that fetches a repository collection with cursor-based pagination and prefetching.
6060+ *
6161+ * @param did - DID whose repository should be queried.
6262+ * @param collection - NSID collection to read from.
6363+ * @param limit - Maximum number of records to request per page. Defaults to `5`.
6464+ * @returns {UsePaginatedRecordsResult<T>} Object containing the current page, pagination metadata, and navigation callbacks.
6565+ */
6666+export function usePaginatedRecords<T>({ did, collection, limit = 5 }: UsePaginatedRecordsOptions): UsePaginatedRecordsResult<T> {
6767+ const { endpoint, error: endpointError } = usePdsEndpoint(did);
6868+ const [pages, setPages] = useState<PageData<T>[]>([]);
6969+ const [pageIndex, setPageIndex] = useState(0);
7070+ const [loading, setLoading] = useState(false);
7171+ const [error, setError] = useState<Error | undefined>(undefined);
7272+ const inFlight = useRef<Set<string>>(new Set());
7373+7474+ const resetState = useCallback(() => {
7575+ setPages([]);
7676+ setPageIndex(0);
7777+ setError(undefined);
7878+ }, []);
7979+8080+ const fetchPage = useCallback(async (cursor: string | undefined, targetIndex: number, mode: 'active' | 'prefetch') => {
8181+ if (!did || !endpoint) return;
8282+ const key = `${targetIndex}:${cursor ?? 'start'}`;
8383+ if (inFlight.current.has(key)) return;
8484+ inFlight.current.add(key);
8585+ if (mode === 'active') {
8686+ setLoading(true);
8787+ setError(undefined);
8888+ }
8989+ try {
9090+ const { rpc } = await createAtprotoClient({ service: endpoint });
9191+ const res = await (rpc as unknown as {
9292+ get: (
9393+ nsid: string,
9494+ opts: { params: Record<string, string | number | boolean | undefined> }
9595+ ) => Promise<{ ok: boolean; data: { records: Array<{ uri: string; rkey?: string; value: T }>; cursor?: string } }>;
9696+ }).get('com.atproto.repo.listRecords', {
9797+ params: {
9898+ repo: did,
9999+ collection,
100100+ limit,
101101+ cursor,
102102+ reverse: false
103103+ }
104104+ });
105105+ if (!res.ok) throw new Error('Failed to list records');
106106+ const { records, cursor: nextCursor } = res.data;
107107+ const mapped: PaginatedRecord<T>[] = records.map((item) => ({
108108+ uri: item.uri,
109109+ rkey: item.rkey ?? extractRkey(item.uri),
110110+ value: item.value
111111+ }));
112112+ setPages(prev => {
113113+ const next = [...prev];
114114+ next[targetIndex] = { records: mapped, cursor: nextCursor };
115115+ return next;
116116+ });
117117+ if (mode === 'active') setPageIndex(targetIndex);
118118+ return nextCursor;
119119+ } catch (e) {
120120+ if (mode === 'active') setError(e as Error);
121121+ } finally {
122122+ if (mode === 'active') setLoading(false);
123123+ inFlight.current.delete(key);
124124+ }
125125+ return undefined;
126126+ }, [did, endpoint, collection, limit]);
127127+128128+ useEffect(() => {
129129+ resetState();
130130+ if (!did || !endpoint) return;
131131+ fetchPage(undefined, 0, 'active').catch(() => {
132132+ /* error handled in state */
133133+ });
134134+ }, [did, endpoint, fetchPage, resetState]);
135135+136136+ const currentPage = pages[pageIndex];
137137+ const hasNext = !!currentPage?.cursor || !!pages[pageIndex + 1];
138138+ const hasPrev = pageIndex > 0;
139139+140140+ const loadNext = useCallback(() => {
141141+ const page = pages[pageIndex];
142142+ if (!page?.cursor && !pages[pageIndex + 1]) return;
143143+ if (pages[pageIndex + 1]) {
144144+ setPageIndex(pageIndex + 1);
145145+ return;
146146+ }
147147+ fetchPage(page.cursor, pageIndex + 1, 'active').catch(() => {
148148+ /* handled via error state */
149149+ });
150150+ }, [fetchPage, pageIndex, pages]);
151151+152152+ const loadPrev = useCallback(() => {
153153+ if (pageIndex === 0) return;
154154+ setPageIndex(pageIndex - 1);
155155+ }, [pageIndex]);
156156+157157+ const records = useMemo(() => currentPage?.records ?? [], [currentPage]);
158158+159159+ const effectiveError = error ?? (endpointError as Error | undefined);
160160+161161+ useEffect(() => {
162162+ const cursor = pages[pageIndex]?.cursor;
163163+ if (!cursor) return;
164164+ if (pages[pageIndex + 1]) return;
165165+ fetchPage(cursor, pageIndex + 1, 'prefetch').catch(() => {
166166+ /* ignore prefetch errors */
167167+ });
168168+ }, [fetchPage, pageIndex, pages]);
169169+170170+ return {
171171+ records,
172172+ loading,
173173+ error: effectiveError,
174174+ hasNext,
175175+ hasPrev,
176176+ loadNext,
177177+ loadPrev,
178178+ pageIndex,
179179+ pagesCount: pages.length || (currentPage ? pageIndex + 1 : 0)
180180+ };
181181+}
182182+183183+function extractRkey(uri: string): string {
184184+ const parts = uri.split('/');
185185+ return parts[parts.length - 1];
186186+}
+28
lib/hooks/usePdsEndpoint.ts
···11+import { useEffect, useState } from 'react';
22+import { useAtProto } from '../providers/AtProtoProvider';
33+44+/**
55+ * Resolves the PDS service endpoint for a given DID and tracks loading state.
66+ *
77+ * @param did - DID whose PDS endpoint should be discovered.
88+ * @returns {{ endpoint: string | undefined; error: Error | undefined; loading: boolean }} Object containing the resolved endpoint, error (if any), and loading flag.
99+ */
1010+export function usePdsEndpoint(did: string | undefined) {
1111+ const { resolver } = useAtProto();
1212+ const [endpoint, setEndpoint] = useState<string | undefined>();
1313+ const [error, setError] = useState<Error | undefined>();
1414+ const [loading, setLoading] = useState(false);
1515+1616+ useEffect(() => {
1717+ let cancelled = false;
1818+ if (!did) return;
1919+ setLoading(true);
2020+ resolver.pdsEndpointForDid(did)
2121+ .then(url => { if (!cancelled) setEndpoint(url); })
2222+ .catch(e => { if (!cancelled) setError(e as Error); })
2323+ .finally(() => { if (!cancelled) setLoading(false); });
2424+ return () => { cancelled = true; };
2525+ }, [did, resolver]);
2626+2727+ return { endpoint, error, loading };
2828+}
+41
lib/index.ts
···11+// Master exporter for the AT React component library.
22+33+// Providers & core primitives
44+export * from './providers/AtProtoProvider';
55+export * from './core/AtProtoRecord';
66+77+// Components
88+export * from './components/BlueskyIcon';
99+export * from './components/BlueskyPost';
1010+export * from './components/BlueskyPostList';
1111+export * from './components/BlueskyProfile';
1212+export * from './components/BlueskyQuotePost';
1313+export * from './components/ColorSchemeToggle';
1414+export * from './components/LeafletDocument';
1515+export * from './components/TangledString';
1616+1717+// Hooks
1818+export * from './hooks/useAtProtoRecord';
1919+export * from './hooks/useBlob';
2020+export * from './hooks/useBlueskyProfile';
2121+export * from './hooks/useColorScheme';
2222+export * from './hooks/useDidHandle';
2323+export * from './hooks/useDidResolution';
2424+export * from './hooks/useLatestRecord';
2525+export * from './hooks/usePaginatedRecords';
2626+export * from './hooks/usePdsEndpoint';
2727+2828+// Renderers
2929+export * from './renderers/BlueskyPostRenderer';
3030+export * from './renderers/BlueskyProfileRenderer';
3131+export * from './renderers/LeafletDocumentRenderer';
3232+export * from './renderers/TangledStringRenderer';
3333+3434+// Types
3535+export * from './types/bluesky';
3636+export * from './types/leaflet';
3737+3838+// Utilities
3939+export * from './utils/at-uri';
4040+export * from './utils/atproto-client';
4141+export * from './utils/profile';
···11+// Re-export precise lexicon types from @atcute/bluesky instead of redefining.
22+import type { AppBskyFeedPost, AppBskyActorProfile } from '@atcute/bluesky';
33+44+// The atcute lexicon modules expose Main interface for record input shapes.
55+export type FeedPostRecord = AppBskyFeedPost.Main;
66+export type ProfileRecord = AppBskyActorProfile.Main;