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

formatting

+53 -51
README.md
···
3. Use the hooks to prefetch handles, blobs, or latest records when you want to control the render flow yourself.
```tsx
-
import { AtProtoProvider, BlueskyPost } from 'atproto-ui';
export function App() {
-
return (
-
<AtProtoProvider>
-
<BlueskyPost did="did:plc:example" rkey="3k2aexample" />
-
{/* you can use handles in the components as well. */}
-
<LeafletDocument did="nekomimi.pet" rkey="3m2seagm2222c" />
-
</AtProtoProvider>
-
);
}
```
### Available building blocks
-
| Component / Hook | What it does |
-
| --- | --- |
-
| `AtProtoProvider` | Configures PLC directory (defaults to `https://plc.directory`) and shares protocol clients via React context. |
-
| `BlueskyProfile` | Renders a profile card for a DID/handle. Accepts `fallback`, `loadingIndicator`, `renderer`, and `colorScheme`. |
-
| `BlueskyPost` / `BlueskyQuotePost` | Shows a single Bluesky post, with quotation support, custom renderer overrides, and the same loading/fallback knobs. |
-
| `BlueskyPostList` | Lists the latest posts with built-in pagination (defaults: 5 per page, pagination controls on). |
-
| `TangledString` | Renders a Tangled string (gist-like record) with optional renderer overrides. |
-
| `LeafletDocument` | Displays long-form Leaflet documents with blocks, theme support, and renderer overrides. |
-
| `useDidResolution`, `useLatestRecord`, `usePaginatedRecords`, … | Hook-level access to records if you want to own the markup or prefill components. |
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.
···
`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`.
```tsx
-
import { useLatestRecord, BlueskyPost } from 'atproto-ui';
-
import type { FeedPostRecord } from 'atproto-ui';
const LatestBlueskyPost: React.FC<{ did: string }> = ({ did }) => {
-
const { rkey, loading, error, empty } = useLatestRecord<FeedPostRecord>(did, 'app.bsky.feed.post');
-
if (loading) return <p>Fetching latest post…</p>;
-
if (error) return <p>Could not load: {error.message}</p>;
-
if (empty || !rkey) return <p>No posts yet.</p>;
-
return (
-
<BlueskyPost
-
did={did}
-
rkey={rkey}
-
colorScheme="system"
-
/>
-
);
};
```
···
```tsx
const LatestLeafletDocument: React.FC<{ did: string }> = ({ did }) => {
-
const { rkey } = useLatestRecord(did, 'pub.leaflet.document');
-
return rkey ? <LeafletDocument did={did} rkey={rkey} colorScheme="light" /> : null;
};
```
···
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:
```tsx
-
import { useLatestRecord, useColorScheme, AtProtoRecord } from 'atproto-ui';
-
import type { FeedPostRecord } from 'atproto-ui';
const LatestPostSummary: React.FC<{ did: string }> = ({ did }) => {
-
const scheme = useColorScheme('system');
-
const { rkey, loading, error } = useLatestRecord<FeedPostRecord>(did, 'app.bsky.feed.post');
-
if (loading) return <span>Loading…</span>;
-
if (error || !rkey) return <span>No post yet.</span>;
-
return (
-
<AtProtoRecord<FeedPostRecord>
-
did={did}
-
collection="app.bsky.feed.post"
-
rkey={rkey}
-
renderer={({ record }) => (
-
<article data-color-scheme={scheme}>
-
<strong>{record?.text ?? 'Empty post'}</strong>
-
</article>
-
)}
-
/>
-
);
};
```
···
- Expand renderer coverage (e.g., Grain.social photos).
- Expand documentation with TypeScript API references and theming guidelines.
-
Contributions and ideas are welcome—feel free to open an issue or PR!
···
3. Use the hooks to prefetch handles, blobs, or latest records when you want to control the render flow yourself.
```tsx
+
import { AtProtoProvider, BlueskyPost } from "atproto-ui";
export function App() {
+
return (
+
<AtProtoProvider>
+
<BlueskyPost did="did:plc:example" rkey="3k2aexample" />
+
{/* you can use handles in the components as well. */}
+
<LeafletDocument did="nekomimi.pet" rkey="3m2seagm2222c" />
+
</AtProtoProvider>
+
);
}
```
### Available building blocks
+
| Component / Hook | What it does |
+
| --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
+
| `AtProtoProvider` | Configures PLC directory (defaults to `https://plc.directory`) and shares protocol clients via React context. |
+
| `BlueskyProfile` | Renders a profile card for a DID/handle. Accepts `fallback`, `loadingIndicator`, `renderer`, and `colorScheme`. |
+
| `BlueskyPost` / `BlueskyQuotePost` | Shows a single Bluesky post, with quotation support, custom renderer overrides, and the same loading/fallback knobs. |
+
| `BlueskyPostList` | Lists the latest posts with built-in pagination (defaults: 5 per page, pagination controls on). |
+
| `TangledString` | Renders a Tangled string (gist-like record) with optional renderer overrides. |
+
| `LeafletDocument` | Displays long-form Leaflet documents with blocks, theme support, and renderer overrides. |
+
| `useDidResolution`, `useLatestRecord`, `usePaginatedRecords`, … | Hook-level access to records if you want to own the markup or prefill components. |
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.
···
`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`.
```tsx
+
import { useLatestRecord, BlueskyPost } from "atproto-ui";
+
import type { FeedPostRecord } from "atproto-ui";
const LatestBlueskyPost: React.FC<{ did: string }> = ({ did }) => {
+
const { rkey, loading, error, empty } = useLatestRecord<FeedPostRecord>(
+
did,
+
"app.bsky.feed.post",
+
);
+
if (loading) return <p>Fetching latest post…</p>;
+
if (error) return <p>Could not load: {error.message}</p>;
+
if (empty || !rkey) return <p>No posts yet.</p>;
+
return <BlueskyPost did={did} rkey={rkey} colorScheme="system" />;
};
```
···
```tsx
const LatestLeafletDocument: React.FC<{ did: string }> = ({ did }) => {
+
const { rkey } = useLatestRecord(did, "pub.leaflet.document");
+
return rkey ? (
+
<LeafletDocument did={did} rkey={rkey} colorScheme="light" />
+
) : null;
};
```
···
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:
```tsx
+
import { useLatestRecord, useColorScheme, AtProtoRecord } from "atproto-ui";
+
import type { FeedPostRecord } from "atproto-ui";
const LatestPostSummary: React.FC<{ did: string }> = ({ did }) => {
+
const scheme = useColorScheme("system");
+
const { rkey, loading, error } = useLatestRecord<FeedPostRecord>(
+
did,
+
"app.bsky.feed.post",
+
);
+
if (loading) return <span>Loading…</span>;
+
if (error || !rkey) return <span>No post yet.</span>;
+
return (
+
<AtProtoRecord<FeedPostRecord>
+
did={did}
+
collection="app.bsky.feed.post"
+
rkey={rkey}
+
renderer={({ record }) => (
+
<article data-color-scheme={scheme}>
+
<strong>{record?.text ?? "Empty post"}</strong>
+
</article>
+
)}
+
/>
+
);
};
```
···
- Expand renderer coverage (e.g., Grain.social photos).
- Expand documentation with TypeScript API references and theming guidelines.
+
Contributions and ideas are welcome—feel free to open an issue or PR!
+36 -32
lib/components/BlueskyIcon.tsx
···
-
import React from 'react';
/**
* Configuration for the `BlueskyIcon` component.
*/
export interface BlueskyIconProps {
-
/**
-
* Pixel dimensions applied to both the width and height of the SVG element.
-
* Defaults to `16`.
-
*/
-
size?: number;
-
/**
-
* Hex, RGB, or any valid CSS color string used to fill the icon path.
-
* Defaults to the standard Bluesky blue `#1185fe`.
-
*/
-
color?: string;
-
/**
-
* Accessible title that will be exposed via `aria-label` for screen readers.
-
* Defaults to `'Bluesky'`.
-
*/
-
title?: string;
}
/**
···
* @param title - Accessible label exposed via `aria-label`.
* @returns A JSX `<svg>` element suitable for inline usage.
*/
-
export const BlueskyIcon: React.FC<BlueskyIconProps> = ({ size = 16, color = '#1185fe', title = 'Bluesky' }) => (
-
<svg
-
xmlns="http://www.w3.org/2000/svg"
-
width={size}
-
height={size}
-
viewBox="0 0 16 16"
-
role="img"
-
aria-label={title}
-
focusable="false"
-
style={{ display: 'block' }}
-
>
-
<path
-
fill={color}
-
d="M3.468 1.948C5.303 3.325 7.276 6.118 8 7.616c.725-1.498 2.698-4.29 4.532-5.668C13.855.955 16 .186 16 2.632c0 .489-.28 4.105-.444 4.692-.572 2.04-2.653 2.561-4.504 2.246 3.236.551 4.06 2.375 2.281 4.2-3.376 3.464-4.852-.87-5.23-1.98-.07-.204-.103-.3-.103-.218 0-.081-.033.014-.102.218-.379 1.11-1.855 5.444-5.231 1.98-1.778-1.825-.955-3.65 2.28-4.2-1.85.315-3.932-.205-4.503-2.246C.28 6.737 0 3.12 0 2.632 0 .186 2.145.955 3.468 1.948"
-
/>
-
</svg>
);
export default BlueskyIcon;
···
+
import React from "react";
/**
* Configuration for the `BlueskyIcon` component.
*/
export interface BlueskyIconProps {
+
/**
+
* Pixel dimensions applied to both the width and height of the SVG element.
+
* Defaults to `16`.
+
*/
+
size?: number;
+
/**
+
* Hex, RGB, or any valid CSS color string used to fill the icon path.
+
* Defaults to the standard Bluesky blue `#1185fe`.
+
*/
+
color?: string;
+
/**
+
* Accessible title that will be exposed via `aria-label` for screen readers.
+
* Defaults to `'Bluesky'`.
+
*/
+
title?: string;
}
/**
···
* @param title - Accessible label exposed via `aria-label`.
* @returns A JSX `<svg>` element suitable for inline usage.
*/
+
export const BlueskyIcon: React.FC<BlueskyIconProps> = ({
+
size = 16,
+
color = "#1185fe",
+
title = "Bluesky",
+
}) => (
+
<svg
+
xmlns="http://www.w3.org/2000/svg"
+
width={size}
+
height={size}
+
viewBox="0 0 16 16"
+
role="img"
+
aria-label={title}
+
focusable="false"
+
style={{ display: "block" }}
+
>
+
<path
+
fill={color}
+
d="M3.468 1.948C5.303 3.325 7.276 6.118 8 7.616c.725-1.498 2.698-4.29 4.532-5.668C13.855.955 16 .186 16 2.632c0 .489-.28 4.105-.444 4.692-.572 2.04-2.653 2.561-4.504 2.246 3.236.551 4.06 2.375 2.281 4.2-3.376 3.464-4.852-.87-5.23-1.98-.07-.204-.103-.3-.103-.218 0-.081-.033.014-.102.218-.379 1.11-1.855 5.444-5.231 1.98-1.778-1.825-.955-3.65 2.28-4.2-1.85.315-3.932-.205-4.503-2.246C.28 6.737 0 3.12 0 2.632 0 .186 2.145.955 3.468 1.948"
+
/>
+
</svg>
);
export default BlueskyIcon;
+176 -134
lib/components/BlueskyPost.tsx
···
-
import React, { useMemo } from 'react';
-
import { AtProtoRecord } from '../core/AtProtoRecord';
-
import { BlueskyPostRenderer } from '../renderers/BlueskyPostRenderer';
-
import type { FeedPostRecord, ProfileRecord } from '../types/bluesky';
-
import { useDidResolution } from '../hooks/useDidResolution';
-
import { useAtProtoRecord } from '../hooks/useAtProtoRecord';
-
import { useBlob } from '../hooks/useBlob';
-
import { BLUESKY_PROFILE_COLLECTION } from './BlueskyProfile';
-
import { getAvatarCid } from '../utils/profile';
-
import { formatDidForLabel } from '../utils/at-uri';
/**
* Props for rendering a single Bluesky post with optional customization hooks.
*/
export interface BlueskyPostProps {
-
/**
-
* Decentralized identifier for the repository that owns the post.
-
*/
-
did: string;
-
/**
-
* Record key identifying the specific post within the collection.
-
*/
-
rkey: string;
-
/**
-
* Custom renderer component that receives resolved post data and status flags.
-
*/
-
renderer?: React.ComponentType<BlueskyPostRendererInjectedProps>;
-
/**
-
* React node shown while the post query has not yet produced data or an error.
-
*/
-
fallback?: React.ReactNode;
-
/**
-
* React node displayed while the post fetch is actively loading.
-
*/
-
loadingIndicator?: React.ReactNode;
-
/**
-
* Preferred color scheme to pass through to renderers.
-
*/
-
colorScheme?: 'light' | 'dark' | 'system';
-
/**
-
* Whether the default renderer should show the Bluesky icon.
-
* Defaults to `true`.
-
*/
-
showIcon?: boolean;
-
/**
-
* Placement strategy for the icon when it is rendered.
-
* Defaults to `'timestamp'`.
-
*/
-
iconPlacement?: 'cardBottomRight' | 'timestamp' | 'linkInline';
}
/**
* Values injected by `BlueskyPost` into a downstream renderer component.
*/
export type BlueskyPostRendererInjectedProps = {
-
/**
-
* Resolved record payload for the post.
-
*/
-
record: FeedPostRecord;
-
/**
-
* `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 DID that owns the post record.
-
*/
-
authorDid: string;
-
/**
-
* Resolved URL for the author's avatar blob, if available.
-
*/
-
avatarUrl?: string;
-
/**
-
* Preferred color scheme bubbled down to children.
-
*/
-
colorScheme?: 'light' | 'dark' | 'system';
-
/**
-
* Placement strategy for the Bluesky icon.
-
*/
-
iconPlacement?: 'cardBottomRight' | 'timestamp' | 'linkInline';
-
/**
-
* Controls whether the icon should render at all.
-
*/
-
showIcon?: boolean;
-
/**
-
* Fully qualified AT URI of the post, when resolvable.
-
*/
-
atUri?: string;
-
/**
-
* Optional override for the rendered embed contents.
-
*/
-
embed?: React.ReactNode;
};
/** NSID for the canonical Bluesky feed post collection. */
-
export const BLUESKY_POST_COLLECTION = 'app.bsky.feed.post';
/**
* Fetches a Bluesky feed post, resolves metadata such as author handle and avatar,
···
* @param iconPlacement - Determines where the icon is positioned in the rendered post. Defaults to `'timestamp'`.
* @returns A component that renders loading/fallback states and the resolved post.
*/
-
export const BlueskyPost: React.FC<BlueskyPostProps> = ({ did: handleOrDid, rkey, renderer, fallback, loadingIndicator, colorScheme, showIcon = true, iconPlacement = 'timestamp' }) => {
-
const { did: resolvedDid, handle, loading: resolvingIdentity, error: resolutionError } = useDidResolution(handleOrDid);
-
const repoIdentifier = resolvedDid ?? handleOrDid;
-
const { record: profile } = useAtProtoRecord<ProfileRecord>({ did: repoIdentifier, collection: BLUESKY_PROFILE_COLLECTION, rkey: 'self' });
-
const avatarCid = getAvatarCid(profile);
-
const Comp: React.ComponentType<BlueskyPostRendererInjectedProps> = renderer ?? ((props) => <BlueskyPostRenderer {...props} />);
-
const displayHandle = handle ?? (handleOrDid.startsWith('did:') ? undefined : handleOrDid);
-
const authorHandle = displayHandle ?? formatDidForLabel(resolvedDid ?? handleOrDid);
-
const atUri = resolvedDid ? `at://${resolvedDid}/${BLUESKY_POST_COLLECTION}/${rkey}` : undefined;
-
const Wrapped = useMemo(() => {
-
const WrappedComponent: React.FC<{ record: FeedPostRecord; loading: boolean; error?: Error }> = (props) => {
-
const { url: avatarUrl } = useBlob(repoIdentifier, avatarCid);
-
return (
-
<Comp
-
{...props}
-
authorHandle={authorHandle}
-
authorDid={repoIdentifier}
-
avatarUrl={avatarUrl}
-
colorScheme={colorScheme}
-
iconPlacement={iconPlacement}
-
showIcon={showIcon}
-
atUri={atUri}
-
/>
-
);
-
};
-
WrappedComponent.displayName = 'BlueskyPostWrappedRenderer';
-
return WrappedComponent;
-
}, [Comp, repoIdentifier, avatarCid, authorHandle, colorScheme, iconPlacement, showIcon, atUri]);
-
if (!displayHandle && resolvingIdentity) {
-
return <div style={{ padding: 8 }}>Resolving handle…</div>;
-
}
-
if (!displayHandle && resolutionError) {
-
return <div style={{ padding: 8, color: 'crimson' }}>Could not resolve handle.</div>;
-
}
-
return (
-
<AtProtoRecord<FeedPostRecord>
-
did={repoIdentifier}
-
collection={BLUESKY_POST_COLLECTION}
-
rkey={rkey}
-
renderer={Wrapped}
-
fallback={fallback}
-
loadingIndicator={loadingIndicator}
-
/>
-
);
};
-
export default BlueskyPost;
···
+
import React, { useMemo } from "react";
+
import { AtProtoRecord } from "../core/AtProtoRecord";
+
import { BlueskyPostRenderer } from "../renderers/BlueskyPostRenderer";
+
import type { FeedPostRecord, ProfileRecord } from "../types/bluesky";
+
import { useDidResolution } from "../hooks/useDidResolution";
+
import { useAtProtoRecord } from "../hooks/useAtProtoRecord";
+
import { useBlob } from "../hooks/useBlob";
+
import { BLUESKY_PROFILE_COLLECTION } from "./BlueskyProfile";
+
import { getAvatarCid } from "../utils/profile";
+
import { formatDidForLabel } from "../utils/at-uri";
/**
* Props for rendering a single Bluesky post with optional customization hooks.
*/
export interface BlueskyPostProps {
+
/**
+
* Decentralized identifier for the repository that owns the post.
+
*/
+
did: string;
+
/**
+
* Record key identifying the specific post within the collection.
+
*/
+
rkey: string;
+
/**
+
* Custom renderer component that receives resolved post data and status flags.
+
*/
+
renderer?: React.ComponentType<BlueskyPostRendererInjectedProps>;
+
/**
+
* React node shown while the post query has not yet produced data or an error.
+
*/
+
fallback?: React.ReactNode;
+
/**
+
* React node displayed while the post fetch is actively loading.
+
*/
+
loadingIndicator?: React.ReactNode;
+
/**
+
* Preferred color scheme to pass through to renderers.
+
*/
+
colorScheme?: "light" | "dark" | "system";
+
/**
+
* Whether the default renderer should show the Bluesky icon.
+
* Defaults to `true`.
+
*/
+
showIcon?: boolean;
+
/**
+
* Placement strategy for the icon when it is rendered.
+
* Defaults to `'timestamp'`.
+
*/
+
iconPlacement?: "cardBottomRight" | "timestamp" | "linkInline";
}
/**
* Values injected by `BlueskyPost` into a downstream renderer component.
*/
export type BlueskyPostRendererInjectedProps = {
+
/**
+
* Resolved record payload for the post.
+
*/
+
record: FeedPostRecord;
+
/**
+
* `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 DID that owns the post record.
+
*/
+
authorDid: string;
+
/**
+
* Resolved URL for the author's avatar blob, if available.
+
*/
+
avatarUrl?: string;
+
/**
+
* Preferred color scheme bubbled down to children.
+
*/
+
colorScheme?: "light" | "dark" | "system";
+
/**
+
* Placement strategy for the Bluesky icon.
+
*/
+
iconPlacement?: "cardBottomRight" | "timestamp" | "linkInline";
+
/**
+
* Controls whether the icon should render at all.
+
*/
+
showIcon?: boolean;
+
/**
+
* Fully qualified AT URI of the post, when resolvable.
+
*/
+
atUri?: string;
+
/**
+
* Optional override for the rendered embed contents.
+
*/
+
embed?: React.ReactNode;
};
/** NSID for the canonical Bluesky feed post collection. */
+
export const BLUESKY_POST_COLLECTION = "app.bsky.feed.post";
/**
* Fetches a Bluesky feed post, resolves metadata such as author handle and avatar,
···
* @param iconPlacement - Determines where the icon is positioned in the rendered post. Defaults to `'timestamp'`.
* @returns A component that renders loading/fallback states and the resolved post.
*/
+
export const BlueskyPost: React.FC<BlueskyPostProps> = ({
+
did: handleOrDid,
+
rkey,
+
renderer,
+
fallback,
+
loadingIndicator,
+
colorScheme,
+
showIcon = true,
+
iconPlacement = "timestamp",
+
}) => {
+
const {
+
did: resolvedDid,
+
handle,
+
loading: resolvingIdentity,
+
error: resolutionError,
+
} = useDidResolution(handleOrDid);
+
const repoIdentifier = resolvedDid ?? handleOrDid;
+
const { record: profile } = useAtProtoRecord<ProfileRecord>({
+
did: repoIdentifier,
+
collection: BLUESKY_PROFILE_COLLECTION,
+
rkey: "self",
+
});
+
const avatarCid = getAvatarCid(profile);
+
const Comp: React.ComponentType<BlueskyPostRendererInjectedProps> = useMemo(
+
() => renderer ?? ((props) => <BlueskyPostRenderer {...props} />),
+
[renderer]
+
);
+
const displayHandle =
+
handle ?? (handleOrDid.startsWith("did:") ? undefined : handleOrDid);
+
const authorHandle =
+
displayHandle ?? formatDidForLabel(resolvedDid ?? handleOrDid);
+
const atUri = resolvedDid
+
? `at://${resolvedDid}/${BLUESKY_POST_COLLECTION}/${rkey}`
+
: undefined;
+
const Wrapped = useMemo(() => {
+
const WrappedComponent: React.FC<{
+
record: FeedPostRecord;
+
loading: boolean;
+
error?: Error;
+
}> = (props) => {
+
const { url: avatarUrl } = useBlob(repoIdentifier, avatarCid);
+
return (
+
<Comp
+
{...props}
+
authorHandle={authorHandle}
+
authorDid={repoIdentifier}
+
avatarUrl={avatarUrl}
+
colorScheme={colorScheme}
+
iconPlacement={iconPlacement}
+
showIcon={showIcon}
+
atUri={atUri}
+
/>
+
);
+
};
+
WrappedComponent.displayName = "BlueskyPostWrappedRenderer";
+
return WrappedComponent;
+
}, [
+
Comp,
+
repoIdentifier,
+
avatarCid,
+
authorHandle,
+
colorScheme,
+
iconPlacement,
+
showIcon,
+
atUri,
+
]);
+
if (!displayHandle && resolvingIdentity) {
+
return <div style={{ padding: 8 }}>Resolving handle…</div>;
+
}
+
if (!displayHandle && resolutionError) {
+
return (
+
<div style={{ padding: 8, color: "crimson" }}>
+
Could not resolve handle.
+
</div>
+
);
+
}
+
return (
+
<AtProtoRecord<FeedPostRecord>
+
did={repoIdentifier}
+
collection={BLUESKY_POST_COLLECTION}
+
rkey={rkey}
+
renderer={Wrapped}
+
fallback={fallback}
+
loadingIndicator={loadingIndicator}
+
/>
+
);
};
+
export default BlueskyPost;
+558 -427
lib/components/BlueskyPostList.tsx
···
-
import React, { useMemo } from 'react';
-
import { usePaginatedRecords, type AuthorFeedReason, type ReplyParentInfo } from '../hooks/usePaginatedRecords';
-
import { useColorScheme } from '../hooks/useColorScheme';
-
import type { FeedPostRecord } from '../types/bluesky';
-
import { useDidResolution } from '../hooks/useDidResolution';
-
import { BlueskyIcon } from './BlueskyIcon';
-
import { parseAtUri } from '../utils/at-uri';
/**
* Options for rendering a paginated list of Bluesky posts.
*/
export interface BlueskyPostListProps {
-
/**
-
* DID whose feed posts should be fetched.
-
*/
-
did: string;
-
/**
-
* Maximum number of records to list per page. Defaults to `5`.
-
*/
-
limit?: number;
-
/**
-
* Enables pagination controls when `true`. Defaults to `true`.
-
*/
-
enablePagination?: boolean;
-
/**
-
* Preferred color scheme passed through to styling helpers.
-
* Defaults to `'system'` which follows the OS preference.
-
*/
-
colorScheme?: 'light' | 'dark' | 'system';
}
/**
···
* @param colorScheme - Preferred color scheme used for styling. Default `'system'`.
* @returns A card-like list element with loading, empty, and error handling.
*/
-
export const BlueskyPostList: React.FC<BlueskyPostListProps> = ({ did, limit = 5, enablePagination = true, colorScheme = 'system' }) => {
-
const scheme = useColorScheme(colorScheme);
-
const palette: ListPalette = scheme === 'dark' ? darkPalette : lightPalette;
-
const { handle: resolvedHandle, did: resolvedDid } = useDidResolution(did);
-
const actorLabel = resolvedHandle ?? formatDid(did);
-
const actorPath = resolvedHandle ?? resolvedDid ?? did;
-
const { records, loading, error, hasNext, hasPrev, loadNext, loadPrev, pageIndex, pagesCount } = usePaginatedRecords<FeedPostRecord>({
-
did,
-
collection: 'app.bsky.feed.post',
-
limit,
-
preferAuthorFeed: true,
-
authorFeedActor: actorPath
-
});
-
const pageLabel = useMemo(() => {
-
const knownTotal = Math.max(pageIndex + 1, pagesCount);
-
if (!enablePagination) return undefined;
-
if (hasNext && knownTotal === pageIndex + 1) return `${pageIndex + 1}/…`;
-
return `${pageIndex + 1}/${knownTotal}`;
-
}, [enablePagination, hasNext, pageIndex, pagesCount]);
-
if (error) return <div style={{ padding: 8, color: 'crimson' }}>Failed to load posts.</div>;
-
return (
-
<div style={{ ...listStyles.card, ...palette.card }}>
-
<div style={{ ...listStyles.header, ...palette.header }}>
-
<div style={listStyles.headerInfo}>
-
<div style={listStyles.headerIcon}>
-
<BlueskyIcon size={20} />
-
</div>
-
<div style={listStyles.headerText}>
-
<span style={listStyles.title}>Latest Posts</span>
-
<span style={{ ...listStyles.subtitle, ...palette.subtitle }}>@{actorLabel}</span>
-
</div>
-
</div>
-
{pageLabel && <span style={{ ...listStyles.pageMeta, ...palette.pageMeta }}>{pageLabel}</span>}
-
</div>
-
<div style={listStyles.items}>
-
{loading && records.length === 0 && <div style={{ ...listStyles.empty, ...palette.empty }}>Loading posts…</div>}
-
{records.map((record, idx) => (
-
<ListRow
-
key={record.rkey}
-
record={record.value}
-
rkey={record.rkey}
-
did={actorPath}
-
reason={record.reason}
-
replyParent={record.replyParent}
-
palette={palette}
-
hasDivider={idx < records.length - 1}
-
/>
-
))}
-
{!loading && records.length === 0 && <div style={{ ...listStyles.empty, ...palette.empty }}>No posts found.</div>}
-
</div>
-
{enablePagination && (
-
<div style={{ ...listStyles.footer, ...palette.footer }}>
-
<button
-
type="button"
-
style={{
-
...listStyles.navButton,
-
...palette.navButton,
-
cursor: hasPrev ? 'pointer' : 'not-allowed',
-
opacity: hasPrev ? 1 : 0.5
-
}}
-
onClick={loadPrev}
-
disabled={!hasPrev}
-
>
-
‹ Prev
-
</button>
-
<div style={listStyles.pageChips}>
-
<span style={{ ...listStyles.pageChipActive, ...palette.pageChipActive }}>{pageIndex + 1}</span>
-
{(hasNext || pagesCount > pageIndex + 1) && (
-
<span style={{ ...listStyles.pageChip, ...palette.pageChip }}>{pageIndex + 2}</span>
-
)}
-
</div>
-
<button
-
type="button"
-
style={{
-
...listStyles.navButton,
-
...palette.navButton,
-
cursor: hasNext ? 'pointer' : 'not-allowed',
-
opacity: hasNext ? 1 : 0.5
-
}}
-
onClick={loadNext}
-
disabled={!hasNext}
-
>
-
Next ›
-
</button>
-
</div>
-
)}
-
{loading && records.length > 0 && <div style={{ ...listStyles.loadingBar, ...palette.loadingBar }}>Updating…</div>}
-
</div>
-
);
};
interface ListRowProps {
-
record: FeedPostRecord;
-
rkey: string;
-
did: string;
-
reason?: AuthorFeedReason;
-
replyParent?: ReplyParentInfo;
-
palette: ListPalette;
-
hasDivider: boolean;
}
-
const ListRow: React.FC<ListRowProps> = ({ record, rkey, did, reason, replyParent, palette, hasDivider }) => {
-
const text = record.text?.trim() ?? '';
-
const relative = record.createdAt ? formatRelativeTime(record.createdAt) : undefined;
-
const absolute = record.createdAt ? new Date(record.createdAt).toLocaleString() : undefined;
-
const href = `https://bsky.app/profile/${did}/post/${rkey}`;
-
const repostLabel = reason?.$type === 'app.bsky.feed.defs#reasonRepost'
-
? `${formatActor(reason.by) ?? 'Someone'} reposted`
-
: undefined;
-
const parentUri = replyParent?.uri ?? record.reply?.parent?.uri;
-
const parentDid = replyParent?.author?.did ?? (parentUri ? parseAtUri(parentUri)?.did : undefined);
-
const { handle: resolvedReplyHandle } = useDidResolution(
-
replyParent?.author?.handle ? undefined : parentDid
-
);
-
const replyLabel = formatReplyTarget(parentUri, replyParent, resolvedReplyHandle);
-
return (
-
<a href={href} target="_blank" rel="noopener noreferrer" style={{ ...listStyles.row, ...palette.row, borderBottom: hasDivider ? `1px solid ${palette.divider}` : 'none' }}>
-
{repostLabel && <span style={{ ...listStyles.rowMeta, ...palette.rowMeta }}>{repostLabel}</span>}
-
{replyLabel && <span style={{ ...listStyles.rowMeta, ...palette.rowMeta }}>{replyLabel}</span>}
-
{relative && (
-
<span style={{ ...listStyles.rowTime, ...palette.rowTime }} title={absolute}>
-
{relative}
-
</span>
-
)}
-
{text && <p style={{ ...listStyles.rowBody, ...palette.rowBody }}>{text}</p>}
-
{!text && <p style={{ ...listStyles.rowBody, ...palette.rowBody, fontStyle: 'italic' }}>No text content.</p>}
-
</a>
-
);
};
function formatDid(did: string) {
-
return did.replace(/^did:(plc:)?/, '');
}
function formatRelativeTime(iso: string): string {
-
const date = new Date(iso);
-
const diffSeconds = (date.getTime() - Date.now()) / 1000;
-
const absSeconds = Math.abs(diffSeconds);
-
const thresholds: Array<{ limit: number; unit: Intl.RelativeTimeFormatUnit; divisor: number }> = [
-
{ limit: 60, unit: 'second', divisor: 1 },
-
{ limit: 3600, unit: 'minute', divisor: 60 },
-
{ limit: 86400, unit: 'hour', divisor: 3600 },
-
{ limit: 604800, unit: 'day', divisor: 86400 },
-
{ limit: 2629800, unit: 'week', divisor: 604800 },
-
{ limit: 31557600, unit: 'month', divisor: 2629800 },
-
{ limit: Infinity, unit: 'year', divisor: 31557600 }
-
];
-
const threshold = thresholds.find(t => absSeconds < t.limit) ?? thresholds[thresholds.length - 1];
-
const value = diffSeconds / threshold.divisor;
-
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
-
return rtf.format(Math.round(value), threshold.unit);
}
interface ListPalette {
-
card: { background: string; borderColor: string };
-
header: { borderBottomColor: string; color: string };
-
pageMeta: { color: string };
-
subtitle: { color: string };
-
empty: { color: string };
-
row: { color: string };
-
rowTime: { color: string };
-
rowBody: { color: string };
-
rowMeta: { color: string };
-
divider: string;
-
footer: { borderTopColor: string; color: string };
-
navButton: { color: string; background: string };
-
pageChip: { color: string; borderColor: string; background: string };
-
pageChipActive: { color: string; background: string; borderColor: string };
-
loadingBar: { color: string };
}
const listStyles = {
-
card: {
-
borderRadius: 16,
-
border: '1px solid transparent',
-
boxShadow: '0 8px 18px -12px rgba(15, 23, 42, 0.25)',
-
overflow: 'hidden',
-
display: 'flex',
-
flexDirection: 'column'
-
} satisfies React.CSSProperties,
-
header: {
-
display: 'flex',
-
alignItems: 'center',
-
justifyContent: 'space-between',
-
padding: '14px 18px',
-
fontSize: 14,
-
fontWeight: 500,
-
borderBottom: '1px solid transparent'
-
} satisfies React.CSSProperties,
-
headerInfo: {
-
display: 'flex',
-
alignItems: 'center',
-
gap: 12
-
} satisfies React.CSSProperties,
-
headerIcon: {
-
width: 28,
-
height: 28,
-
display: 'flex',
-
alignItems: 'center',
-
justifyContent: 'center',
-
//background: 'rgba(17, 133, 254, 0.14)',
-
borderRadius: '50%'
-
} satisfies React.CSSProperties,
-
headerText: {
-
display: 'flex',
-
flexDirection: 'column',
-
gap: 2
-
} satisfies React.CSSProperties,
-
title: {
-
fontSize: 15,
-
fontWeight: 600
-
} satisfies React.CSSProperties,
-
subtitle: {
-
fontSize: 12,
-
fontWeight: 500
-
} satisfies React.CSSProperties,
-
pageMeta: {
-
fontSize: 12
-
} satisfies React.CSSProperties,
-
items: {
-
display: 'flex',
-
flexDirection: 'column'
-
} satisfies React.CSSProperties,
-
empty: {
-
padding: '24px 18px',
-
fontSize: 13,
-
textAlign: 'center'
-
} satisfies React.CSSProperties,
-
row: {
-
padding: '18px',
-
textDecoration: 'none',
-
display: 'flex',
-
flexDirection: 'column',
-
gap: 6,
-
transition: 'background-color 120ms ease'
-
} satisfies React.CSSProperties,
-
rowHeader: {
-
display: 'flex',
-
gap: 6,
-
alignItems: 'baseline',
-
fontSize: 13
-
} satisfies React.CSSProperties,
-
rowTime: {
-
fontSize: 12,
-
fontWeight: 500
-
} satisfies React.CSSProperties,
-
rowMeta: {
-
fontSize: 12,
-
fontWeight: 500,
-
letterSpacing: '0.6px'
-
} satisfies React.CSSProperties,
-
rowBody: {
-
margin: 0,
-
whiteSpace: 'pre-wrap',
-
fontSize: 14,
-
lineHeight: 1.45
-
} satisfies React.CSSProperties,
-
footer: {
-
display: 'flex',
-
alignItems: 'center',
-
justifyContent: 'space-between',
-
padding: '12px 18px',
-
borderTop: '1px solid transparent',
-
fontSize: 13
-
} satisfies React.CSSProperties,
-
navButton: {
-
border: 'none',
-
borderRadius: 999,
-
padding: '6px 12px',
-
fontSize: 13,
-
fontWeight: 500,
-
background: 'transparent',
-
display: 'flex',
-
alignItems: 'center',
-
gap: 4,
-
transition: 'background-color 120ms ease'
-
} satisfies React.CSSProperties,
-
pageChips: {
-
display: 'flex',
-
gap: 6,
-
alignItems: 'center'
-
} satisfies React.CSSProperties,
-
pageChip: {
-
padding: '4px 10px',
-
borderRadius: 999,
-
fontSize: 13,
-
border: '1px solid transparent'
-
} satisfies React.CSSProperties,
-
pageChipActive: {
-
padding: '4px 10px',
-
borderRadius: 999,
-
fontSize: 13,
-
fontWeight: 600,
-
border: '1px solid transparent'
-
} satisfies React.CSSProperties,
-
loadingBar: {
-
padding: '4px 18px 14px',
-
fontSize: 12,
-
textAlign: 'right',
-
color: '#64748b'
-
} satisfies React.CSSProperties
};
const lightPalette: ListPalette = {
-
card: {
-
background: '#ffffff',
-
borderColor: '#e2e8f0'
-
},
-
header: {
-
borderBottomColor: '#e2e8f0',
-
color: '#0f172a'
-
},
-
pageMeta: {
-
color: '#64748b'
-
},
-
subtitle: {
-
color: '#475569'
-
},
-
empty: {
-
color: '#64748b'
-
},
-
row: {
-
color: '#0f172a'
-
},
-
rowTime: {
-
color: '#94a3b8'
-
},
-
rowBody: {
-
color: '#0f172a'
-
},
-
rowMeta: {
-
color: '#64748b'
-
},
-
divider: '#e2e8f0',
-
footer: {
-
borderTopColor: '#e2e8f0',
-
color: '#0f172a'
-
},
-
navButton: {
-
color: '#0f172a',
-
background: '#f1f5f9'
-
},
-
pageChip: {
-
color: '#475569',
-
borderColor: '#e2e8f0',
-
background: '#ffffff'
-
},
-
pageChipActive: {
-
color: '#ffffff',
-
background: '#0f172a',
-
borderColor: '#0f172a'
-
},
-
loadingBar: {
-
color: '#64748b'
-
}
};
const darkPalette: ListPalette = {
-
card: {
-
background: '#0f172a',
-
borderColor: '#1e293b'
-
},
-
header: {
-
borderBottomColor: '#1e293b',
-
color: '#e2e8f0'
-
},
-
pageMeta: {
-
color: '#94a3b8'
-
},
-
subtitle: {
-
color: '#94a3b8'
-
},
-
empty: {
-
color: '#94a3b8'
-
},
-
row: {
-
color: '#e2e8f0'
-
},
-
rowTime: {
-
color: '#94a3b8'
-
},
-
rowBody: {
-
color: '#e2e8f0'
-
},
-
rowMeta: {
-
color: '#94a3b8'
-
},
-
divider: '#1e293b',
-
footer: {
-
borderTopColor: '#1e293b',
-
color: '#e2e8f0'
-
},
-
navButton: {
-
color: '#e2e8f0',
-
background: '#111c31'
-
},
-
pageChip: {
-
color: '#cbd5f5',
-
borderColor: '#1e293b',
-
background: '#0f172a'
-
},
-
pageChipActive: {
-
color: '#0f172a',
-
background: '#38bdf8',
-
borderColor: '#38bdf8'
-
},
-
loadingBar: {
-
color: '#94a3b8'
-
}
};
export default BlueskyPostList;
function formatActor(actor?: { handle?: string; did?: string }) {
-
if (!actor) return undefined;
-
if (actor.handle) return `@${actor.handle}`;
-
if (actor.did) return `@${formatDid(actor.did)}`;
-
return undefined;
}
-
function formatReplyTarget(parentUri?: string, feedParent?: ReplyParentInfo, resolvedHandle?: string) {
-
const directHandle = feedParent?.author?.handle;
-
const handle = directHandle ?? resolvedHandle;
-
if (handle) {
-
return `Replying to @${handle}`;
-
}
-
const parentDid = feedParent?.author?.did;
-
const targetUri = feedParent?.uri ?? parentUri;
-
if (!targetUri) return undefined;
-
const parsed = parseAtUri(targetUri);
-
const did = parentDid ?? parsed?.did;
-
if (!did) return undefined;
-
return `Replying to @${formatDid(did)}`;
}
···
+
import React, { useMemo } from "react";
+
import {
+
usePaginatedRecords,
+
type AuthorFeedReason,
+
type ReplyParentInfo,
+
} from "../hooks/usePaginatedRecords";
+
import { useColorScheme } from "../hooks/useColorScheme";
+
import type { FeedPostRecord } from "../types/bluesky";
+
import { useDidResolution } from "../hooks/useDidResolution";
+
import { BlueskyIcon } from "./BlueskyIcon";
+
import { parseAtUri } from "../utils/at-uri";
/**
* Options for rendering a paginated list of Bluesky posts.
*/
export interface BlueskyPostListProps {
+
/**
+
* DID whose feed posts should be fetched.
+
*/
+
did: string;
+
/**
+
* Maximum number of records to list per page. Defaults to `5`.
+
*/
+
limit?: number;
+
/**
+
* Enables pagination controls when `true`. Defaults to `true`.
+
*/
+
enablePagination?: boolean;
+
/**
+
* Preferred color scheme passed through to styling helpers.
+
* Defaults to `'system'` which follows the OS preference.
+
*/
+
colorScheme?: "light" | "dark" | "system";
}
/**
···
* @param colorScheme - Preferred color scheme used for styling. Default `'system'`.
* @returns A card-like list element with loading, empty, and error handling.
*/
+
export const BlueskyPostList: React.FC<BlueskyPostListProps> = ({
+
did,
+
limit = 5,
+
enablePagination = true,
+
colorScheme = "system",
+
}) => {
+
const scheme = useColorScheme(colorScheme);
+
const palette: ListPalette = scheme === "dark" ? darkPalette : lightPalette;
+
const { handle: resolvedHandle, did: resolvedDid } = useDidResolution(did);
+
const actorLabel = resolvedHandle ?? formatDid(did);
+
const actorPath = resolvedHandle ?? resolvedDid ?? did;
+
const {
+
records,
+
loading,
+
error,
+
hasNext,
+
hasPrev,
+
loadNext,
+
loadPrev,
+
pageIndex,
+
pagesCount,
+
} = usePaginatedRecords<FeedPostRecord>({
+
did,
+
collection: "app.bsky.feed.post",
+
limit,
+
preferAuthorFeed: true,
+
authorFeedActor: actorPath,
+
});
+
const pageLabel = useMemo(() => {
+
const knownTotal = Math.max(pageIndex + 1, pagesCount);
+
if (!enablePagination) return undefined;
+
if (hasNext && knownTotal === pageIndex + 1)
+
return `${pageIndex + 1}/…`;
+
return `${pageIndex + 1}/${knownTotal}`;
+
}, [enablePagination, hasNext, pageIndex, pagesCount]);
+
if (error)
+
return (
+
<div style={{ padding: 8, color: "crimson" }}>
+
Failed to load posts.
+
</div>
+
);
+
return (
+
<div style={{ ...listStyles.card, ...palette.card }}>
+
<div style={{ ...listStyles.header, ...palette.header }}>
+
<div style={listStyles.headerInfo}>
+
<div style={listStyles.headerIcon}>
+
<BlueskyIcon size={20} />
+
</div>
+
<div style={listStyles.headerText}>
+
<span style={listStyles.title}>Latest Posts</span>
+
<span
+
style={{
+
...listStyles.subtitle,
+
...palette.subtitle,
+
}}
+
>
+
@{actorLabel}
+
</span>
+
</div>
+
</div>
+
{pageLabel && (
+
<span
+
style={{ ...listStyles.pageMeta, ...palette.pageMeta }}
+
>
+
{pageLabel}
+
</span>
+
)}
+
</div>
+
<div style={listStyles.items}>
+
{loading && records.length === 0 && (
+
<div style={{ ...listStyles.empty, ...palette.empty }}>
+
Loading posts…
+
</div>
+
)}
+
{records.map((record, idx) => (
+
<ListRow
+
key={record.rkey}
+
record={record.value}
+
rkey={record.rkey}
+
did={actorPath}
+
reason={record.reason}
+
replyParent={record.replyParent}
+
palette={palette}
+
hasDivider={idx < records.length - 1}
+
/>
+
))}
+
{!loading && records.length === 0 && (
+
<div style={{ ...listStyles.empty, ...palette.empty }}>
+
No posts found.
+
</div>
+
)}
+
</div>
+
{enablePagination && (
+
<div style={{ ...listStyles.footer, ...palette.footer }}>
+
<button
+
type="button"
+
style={{
+
...listStyles.navButton,
+
...palette.navButton,
+
cursor: hasPrev ? "pointer" : "not-allowed",
+
opacity: hasPrev ? 1 : 0.5,
+
}}
+
onClick={loadPrev}
+
disabled={!hasPrev}
+
>
+
‹ Prev
+
</button>
+
<div style={listStyles.pageChips}>
+
<span
+
style={{
+
...listStyles.pageChipActive,
+
...palette.pageChipActive,
+
}}
+
>
+
{pageIndex + 1}
+
</span>
+
{(hasNext || pagesCount > pageIndex + 1) && (
+
<span
+
style={{
+
...listStyles.pageChip,
+
...palette.pageChip,
+
}}
+
>
+
{pageIndex + 2}
+
</span>
+
)}
+
</div>
+
<button
+
type="button"
+
style={{
+
...listStyles.navButton,
+
...palette.navButton,
+
cursor: hasNext ? "pointer" : "not-allowed",
+
opacity: hasNext ? 1 : 0.5,
+
}}
+
onClick={loadNext}
+
disabled={!hasNext}
+
>
+
Next ›
+
</button>
+
</div>
+
)}
+
{loading && records.length > 0 && (
+
<div
+
style={{ ...listStyles.loadingBar, ...palette.loadingBar }}
+
>
+
Updating…
+
</div>
+
)}
+
</div>
+
);
};
interface ListRowProps {
+
record: FeedPostRecord;
+
rkey: string;
+
did: string;
+
reason?: AuthorFeedReason;
+
replyParent?: ReplyParentInfo;
+
palette: ListPalette;
+
hasDivider: boolean;
}
+
const ListRow: React.FC<ListRowProps> = ({
+
record,
+
rkey,
+
did,
+
reason,
+
replyParent,
+
palette,
+
hasDivider,
+
}) => {
+
const text = record.text?.trim() ?? "";
+
const relative = record.createdAt
+
? formatRelativeTime(record.createdAt)
+
: undefined;
+
const absolute = record.createdAt
+
? new Date(record.createdAt).toLocaleString()
+
: undefined;
+
const href = `https://bsky.app/profile/${did}/post/${rkey}`;
+
const repostLabel =
+
reason?.$type === "app.bsky.feed.defs#reasonRepost"
+
? `${formatActor(reason.by) ?? "Someone"} reposted`
+
: undefined;
+
const parentUri = replyParent?.uri ?? record.reply?.parent?.uri;
+
const parentDid =
+
replyParent?.author?.did ??
+
(parentUri ? parseAtUri(parentUri)?.did : undefined);
+
const { handle: resolvedReplyHandle } = useDidResolution(
+
replyParent?.author?.handle ? undefined : parentDid,
+
);
+
const replyLabel = formatReplyTarget(
+
parentUri,
+
replyParent,
+
resolvedReplyHandle,
+
);
+
return (
+
<a
+
href={href}
+
target="_blank"
+
rel="noopener noreferrer"
+
style={{
+
...listStyles.row,
+
...palette.row,
+
borderBottom: hasDivider
+
? `1px solid ${palette.divider}`
+
: "none",
+
}}
+
>
+
{repostLabel && (
+
<span style={{ ...listStyles.rowMeta, ...palette.rowMeta }}>
+
{repostLabel}
+
</span>
+
)}
+
{replyLabel && (
+
<span style={{ ...listStyles.rowMeta, ...palette.rowMeta }}>
+
{replyLabel}
+
</span>
+
)}
+
{relative && (
+
<span
+
style={{ ...listStyles.rowTime, ...palette.rowTime }}
+
title={absolute}
+
>
+
{relative}
+
</span>
+
)}
+
{text && (
+
<p style={{ ...listStyles.rowBody, ...palette.rowBody }}>
+
{text}
+
</p>
+
)}
+
{!text && (
+
<p
+
style={{
+
...listStyles.rowBody,
+
...palette.rowBody,
+
fontStyle: "italic",
+
}}
+
>
+
No text content.
+
</p>
+
)}
+
</a>
+
);
};
function formatDid(did: string) {
+
return did.replace(/^did:(plc:)?/, "");
}
function formatRelativeTime(iso: string): string {
+
const date = new Date(iso);
+
const diffSeconds = (date.getTime() - Date.now()) / 1000;
+
const absSeconds = Math.abs(diffSeconds);
+
const thresholds: Array<{
+
limit: number;
+
unit: Intl.RelativeTimeFormatUnit;
+
divisor: number;
+
}> = [
+
{ limit: 60, unit: "second", divisor: 1 },
+
{ limit: 3600, unit: "minute", divisor: 60 },
+
{ limit: 86400, unit: "hour", divisor: 3600 },
+
{ limit: 604800, unit: "day", divisor: 86400 },
+
{ limit: 2629800, unit: "week", divisor: 604800 },
+
{ limit: 31557600, unit: "month", divisor: 2629800 },
+
{ limit: Infinity, unit: "year", divisor: 31557600 },
+
];
+
const threshold =
+
thresholds.find((t) => absSeconds < t.limit) ??
+
thresholds[thresholds.length - 1];
+
const value = diffSeconds / threshold.divisor;
+
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
+
return rtf.format(Math.round(value), threshold.unit);
}
interface ListPalette {
+
card: { background: string; borderColor: string };
+
header: { borderBottomColor: string; color: string };
+
pageMeta: { color: string };
+
subtitle: { color: string };
+
empty: { color: string };
+
row: { color: string };
+
rowTime: { color: string };
+
rowBody: { color: string };
+
rowMeta: { color: string };
+
divider: string;
+
footer: { borderTopColor: string; color: string };
+
navButton: { color: string; background: string };
+
pageChip: { color: string; borderColor: string; background: string };
+
pageChipActive: { color: string; background: string; borderColor: string };
+
loadingBar: { color: string };
}
const listStyles = {
+
card: {
+
borderRadius: 16,
+
border: "1px solid transparent",
+
boxShadow: "0 8px 18px -12px rgba(15, 23, 42, 0.25)",
+
overflow: "hidden",
+
display: "flex",
+
flexDirection: "column",
+
} satisfies React.CSSProperties,
+
header: {
+
display: "flex",
+
alignItems: "center",
+
justifyContent: "space-between",
+
padding: "14px 18px",
+
fontSize: 14,
+
fontWeight: 500,
+
borderBottom: "1px solid transparent",
+
} satisfies React.CSSProperties,
+
headerInfo: {
+
display: "flex",
+
alignItems: "center",
+
gap: 12,
+
} satisfies React.CSSProperties,
+
headerIcon: {
+
width: 28,
+
height: 28,
+
display: "flex",
+
alignItems: "center",
+
justifyContent: "center",
+
//background: 'rgba(17, 133, 254, 0.14)',
+
borderRadius: "50%",
+
} satisfies React.CSSProperties,
+
headerText: {
+
display: "flex",
+
flexDirection: "column",
+
gap: 2,
+
} satisfies React.CSSProperties,
+
title: {
+
fontSize: 15,
+
fontWeight: 600,
+
} satisfies React.CSSProperties,
+
subtitle: {
+
fontSize: 12,
+
fontWeight: 500,
+
} satisfies React.CSSProperties,
+
pageMeta: {
+
fontSize: 12,
+
} satisfies React.CSSProperties,
+
items: {
+
display: "flex",
+
flexDirection: "column",
+
} satisfies React.CSSProperties,
+
empty: {
+
padding: "24px 18px",
+
fontSize: 13,
+
textAlign: "center",
+
} satisfies React.CSSProperties,
+
row: {
+
padding: "18px",
+
textDecoration: "none",
+
display: "flex",
+
flexDirection: "column",
+
gap: 6,
+
transition: "background-color 120ms ease",
+
} satisfies React.CSSProperties,
+
rowHeader: {
+
display: "flex",
+
gap: 6,
+
alignItems: "baseline",
+
fontSize: 13,
+
} satisfies React.CSSProperties,
+
rowTime: {
+
fontSize: 12,
+
fontWeight: 500,
+
} satisfies React.CSSProperties,
+
rowMeta: {
+
fontSize: 12,
+
fontWeight: 500,
+
letterSpacing: "0.6px",
+
} satisfies React.CSSProperties,
+
rowBody: {
+
margin: 0,
+
whiteSpace: "pre-wrap",
+
fontSize: 14,
+
lineHeight: 1.45,
+
} satisfies React.CSSProperties,
+
footer: {
+
display: "flex",
+
alignItems: "center",
+
justifyContent: "space-between",
+
padding: "12px 18px",
+
borderTop: "1px solid transparent",
+
fontSize: 13,
+
} satisfies React.CSSProperties,
+
navButton: {
+
border: "none",
+
borderRadius: 999,
+
padding: "6px 12px",
+
fontSize: 13,
+
fontWeight: 500,
+
background: "transparent",
+
display: "flex",
+
alignItems: "center",
+
gap: 4,
+
transition: "background-color 120ms ease",
+
} satisfies React.CSSProperties,
+
pageChips: {
+
display: "flex",
+
gap: 6,
+
alignItems: "center",
+
} satisfies React.CSSProperties,
+
pageChip: {
+
padding: "4px 10px",
+
borderRadius: 999,
+
fontSize: 13,
+
border: "1px solid transparent",
+
} satisfies React.CSSProperties,
+
pageChipActive: {
+
padding: "4px 10px",
+
borderRadius: 999,
+
fontSize: 13,
+
fontWeight: 600,
+
border: "1px solid transparent",
+
} satisfies React.CSSProperties,
+
loadingBar: {
+
padding: "4px 18px 14px",
+
fontSize: 12,
+
textAlign: "right",
+
color: "#64748b",
+
} satisfies React.CSSProperties,
};
const lightPalette: ListPalette = {
+
card: {
+
background: "#ffffff",
+
borderColor: "#e2e8f0",
+
},
+
header: {
+
borderBottomColor: "#e2e8f0",
+
color: "#0f172a",
+
},
+
pageMeta: {
+
color: "#64748b",
+
},
+
subtitle: {
+
color: "#475569",
+
},
+
empty: {
+
color: "#64748b",
+
},
+
row: {
+
color: "#0f172a",
+
},
+
rowTime: {
+
color: "#94a3b8",
+
},
+
rowBody: {
+
color: "#0f172a",
+
},
+
rowMeta: {
+
color: "#64748b",
+
},
+
divider: "#e2e8f0",
+
footer: {
+
borderTopColor: "#e2e8f0",
+
color: "#0f172a",
+
},
+
navButton: {
+
color: "#0f172a",
+
background: "#f1f5f9",
+
},
+
pageChip: {
+
color: "#475569",
+
borderColor: "#e2e8f0",
+
background: "#ffffff",
+
},
+
pageChipActive: {
+
color: "#ffffff",
+
background: "#0f172a",
+
borderColor: "#0f172a",
+
},
+
loadingBar: {
+
color: "#64748b",
+
},
};
const darkPalette: ListPalette = {
+
card: {
+
background: "#0f172a",
+
borderColor: "#1e293b",
+
},
+
header: {
+
borderBottomColor: "#1e293b",
+
color: "#e2e8f0",
+
},
+
pageMeta: {
+
color: "#94a3b8",
+
},
+
subtitle: {
+
color: "#94a3b8",
+
},
+
empty: {
+
color: "#94a3b8",
+
},
+
row: {
+
color: "#e2e8f0",
+
},
+
rowTime: {
+
color: "#94a3b8",
+
},
+
rowBody: {
+
color: "#e2e8f0",
+
},
+
rowMeta: {
+
color: "#94a3b8",
+
},
+
divider: "#1e293b",
+
footer: {
+
borderTopColor: "#1e293b",
+
color: "#e2e8f0",
+
},
+
navButton: {
+
color: "#e2e8f0",
+
background: "#111c31",
+
},
+
pageChip: {
+
color: "#cbd5f5",
+
borderColor: "#1e293b",
+
background: "#0f172a",
+
},
+
pageChipActive: {
+
color: "#0f172a",
+
background: "#38bdf8",
+
borderColor: "#38bdf8",
+
},
+
loadingBar: {
+
color: "#94a3b8",
+
},
};
export default BlueskyPostList;
function formatActor(actor?: { handle?: string; did?: string }) {
+
if (!actor) return undefined;
+
if (actor.handle) return `@${actor.handle}`;
+
if (actor.did) return `@${formatDid(actor.did)}`;
+
return undefined;
}
+
function formatReplyTarget(
+
parentUri?: string,
+
feedParent?: ReplyParentInfo,
+
resolvedHandle?: string,
+
) {
+
const directHandle = feedParent?.author?.handle;
+
const handle = directHandle ?? resolvedHandle;
+
if (handle) {
+
return `Replying to @${handle}`;
+
}
+
const parentDid = feedParent?.author?.did;
+
const targetUri = feedParent?.uri ?? parentUri;
+
if (!targetUri) return undefined;
+
const parsed = parseAtUri(targetUri);
+
const did = parentDid ?? parsed?.did;
+
if (!did) return undefined;
+
return `Replying to @${formatDid(did)}`;
}
+112 -86
lib/components/BlueskyProfile.tsx
···
-
import React from 'react';
-
import { AtProtoRecord } from '../core/AtProtoRecord';
-
import { BlueskyProfileRenderer } from '../renderers/BlueskyProfileRenderer';
-
import type { ProfileRecord } from '../types/bluesky';
-
import { useBlob } from '../hooks/useBlob';
-
import { getAvatarCid } from '../utils/profile';
-
import { useDidResolution } from '../hooks/useDidResolution';
-
import { formatDidForLabel } from '../utils/at-uri';
/**
* Props used to render a Bluesky actor profile record.
*/
export interface BlueskyProfileProps {
-
/**
-
* DID of the target actor whose profile should be loaded.
-
*/
-
did: string;
-
/**
-
* Record key within the profile collection. Typically `'self'`.
-
*/
-
rkey?: string;
-
/**
-
* Optional renderer override for custom presentation.
-
*/
-
renderer?: React.ComponentType<BlueskyProfileRendererInjectedProps>;
-
/**
-
* Fallback node shown before a request begins yielding data.
-
*/
-
fallback?: React.ReactNode;
-
/**
-
* Loading indicator shown during in-flight fetches.
-
*/
-
loadingIndicator?: React.ReactNode;
-
/**
-
* Pre-resolved handle to display when available externally.
-
*/
-
handle?: string;
-
/**
-
* Preferred color scheme forwarded to renderer implementations.
-
*/
-
colorScheme?: 'light' | 'dark' | 'system';
}
/**
* Props injected into custom profile renderer implementations.
*/
export type BlueskyProfileRendererInjectedProps = {
-
/**
-
* Loaded profile record value.
-
*/
-
record: ProfileRecord;
-
/**
-
* Indicates whether the record is currently being fetched.
-
*/
-
loading: boolean;
-
/**
-
* Any error encountered while fetching the profile.
-
*/
-
error?: Error;
-
/**
-
* DID associated with the profile.
-
*/
-
did: string;
-
/**
-
* Human-readable handle for the DID, when known.
-
*/
-
handle?: string;
-
/**
-
* Blob URL for the user's avatar, when available.
-
*/
-
avatarUrl?: string;
-
/**
-
* Preferred color scheme for theming downstream components.
-
*/
-
colorScheme?: 'light' | 'dark' | 'system';
};
/** NSID for the canonical Bluesky profile collection. */
-
export const BLUESKY_PROFILE_COLLECTION = 'app.bsky.actor.profile';
/**
* Fetches and renders a Bluesky actor profile, optionally injecting custom presentation
···
* @param colorScheme - Preferred color scheme forwarded to the renderer.
* @returns A rendered profile component with loading/error states handled.
*/
-
export const BlueskyProfile: React.FC<BlueskyProfileProps> = ({ did: handleOrDid, rkey = 'self', renderer, fallback, loadingIndicator, handle, colorScheme }) => {
-
const Component: React.ComponentType<BlueskyProfileRendererInjectedProps> = renderer ?? ((props) => <BlueskyProfileRenderer {...props} />);
-
const { did, handle: resolvedHandle } = useDidResolution(handleOrDid);
-
const repoIdentifier = did ?? handleOrDid;
-
const effectiveHandle = handle ?? resolvedHandle ?? (handleOrDid.startsWith('did:') ? formatDidForLabel(repoIdentifier) : handleOrDid);
-
const Wrapped: React.FC<{ record: ProfileRecord; loading: boolean; error?: Error }> = (props) => {
-
const avatarCid = getAvatarCid(props.record);
-
const { url: avatarUrl } = useBlob(repoIdentifier, avatarCid);
-
return <Component {...props} did={repoIdentifier} handle={effectiveHandle} avatarUrl={avatarUrl} colorScheme={colorScheme} />;
-
};
-
return (
-
<AtProtoRecord<ProfileRecord>
-
did={repoIdentifier}
-
collection={BLUESKY_PROFILE_COLLECTION}
-
rkey={rkey}
-
renderer={Wrapped}
-
fallback={fallback}
-
loadingIndicator={loadingIndicator}
-
/>
-
);
};
-
export default BlueskyProfile;
···
+
import React from "react";
+
import { AtProtoRecord } from "../core/AtProtoRecord";
+
import { BlueskyProfileRenderer } from "../renderers/BlueskyProfileRenderer";
+
import type { ProfileRecord } from "../types/bluesky";
+
import { useBlob } from "../hooks/useBlob";
+
import { getAvatarCid } from "../utils/profile";
+
import { useDidResolution } from "../hooks/useDidResolution";
+
import { formatDidForLabel } from "../utils/at-uri";
/**
* Props used to render a Bluesky actor profile record.
*/
export interface BlueskyProfileProps {
+
/**
+
* DID of the target actor whose profile should be loaded.
+
*/
+
did: string;
+
/**
+
* Record key within the profile collection. Typically `'self'`.
+
*/
+
rkey?: string;
+
/**
+
* Optional renderer override for custom presentation.
+
*/
+
renderer?: React.ComponentType<BlueskyProfileRendererInjectedProps>;
+
/**
+
* Fallback node shown before a request begins yielding data.
+
*/
+
fallback?: React.ReactNode;
+
/**
+
* Loading indicator shown during in-flight fetches.
+
*/
+
loadingIndicator?: React.ReactNode;
+
/**
+
* Pre-resolved handle to display when available externally.
+
*/
+
handle?: string;
+
/**
+
* Preferred color scheme forwarded to renderer implementations.
+
*/
+
colorScheme?: "light" | "dark" | "system";
}
/**
* Props injected into custom profile renderer implementations.
*/
export type BlueskyProfileRendererInjectedProps = {
+
/**
+
* Loaded profile record value.
+
*/
+
record: ProfileRecord;
+
/**
+
* Indicates whether the record is currently being fetched.
+
*/
+
loading: boolean;
+
/**
+
* Any error encountered while fetching the profile.
+
*/
+
error?: Error;
+
/**
+
* DID associated with the profile.
+
*/
+
did: string;
+
/**
+
* Human-readable handle for the DID, when known.
+
*/
+
handle?: string;
+
/**
+
* Blob URL for the user's avatar, when available.
+
*/
+
avatarUrl?: string;
+
/**
+
* Preferred color scheme for theming downstream components.
+
*/
+
colorScheme?: "light" | "dark" | "system";
};
/** NSID for the canonical Bluesky profile collection. */
+
export const BLUESKY_PROFILE_COLLECTION = "app.bsky.actor.profile";
/**
* Fetches and renders a Bluesky actor profile, optionally injecting custom presentation
···
* @param colorScheme - Preferred color scheme forwarded to the renderer.
* @returns A rendered profile component with loading/error states handled.
*/
+
export const BlueskyProfile: React.FC<BlueskyProfileProps> = ({
+
did: handleOrDid,
+
rkey = "self",
+
renderer,
+
fallback,
+
loadingIndicator,
+
handle,
+
colorScheme,
+
}) => {
+
const Component: React.ComponentType<BlueskyProfileRendererInjectedProps> =
+
renderer ?? ((props) => <BlueskyProfileRenderer {...props} />);
+
const { did, handle: resolvedHandle } = useDidResolution(handleOrDid);
+
const repoIdentifier = did ?? handleOrDid;
+
const effectiveHandle =
+
handle ??
+
resolvedHandle ??
+
(handleOrDid.startsWith("did:")
+
? formatDidForLabel(repoIdentifier)
+
: handleOrDid);
+
const Wrapped: React.FC<{
+
record: ProfileRecord;
+
loading: boolean;
+
error?: Error;
+
}> = (props) => {
+
const avatarCid = getAvatarCid(props.record);
+
const { url: avatarUrl } = useBlob(repoIdentifier, avatarCid);
+
return (
+
<Component
+
{...props}
+
did={repoIdentifier}
+
handle={effectiveHandle}
+
avatarUrl={avatarUrl}
+
colorScheme={colorScheme}
+
/>
+
);
+
};
+
return (
+
<AtProtoRecord<ProfileRecord>
+
did={repoIdentifier}
+
collection={BLUESKY_PROFILE_COLLECTION}
+
rkey={rkey}
+
renderer={Wrapped}
+
fallback={fallback}
+
loadingIndicator={loadingIndicator}
+
/>
+
);
};
+
export default BlueskyProfile;
+108 -82
lib/components/BlueskyQuotePost.tsx
···
-
import React, { memo, useMemo, type NamedExoticComponent } from 'react';
-
import { BlueskyPost, type BlueskyPostRendererInjectedProps, BLUESKY_POST_COLLECTION } from './BlueskyPost';
-
import { BlueskyPostRenderer } from '../renderers/BlueskyPostRenderer';
-
import { parseAtUri } from '../utils/at-uri';
/**
* Props for rendering a Bluesky post that quotes another Bluesky post.
*/
export interface BlueskyQuotePostProps {
-
/**
-
* DID of the repository that owns the parent post.
-
*/
-
did: string;
-
/**
-
* Record key of the parent post.
-
*/
-
rkey: string;
-
/**
-
* Preferred color scheme propagated to nested renders.
-
*/
-
colorScheme?: 'light' | 'dark' | 'system';
-
/**
-
* Custom renderer override applied to the parent post.
-
*/
-
renderer?: React.ComponentType<BlueskyPostRendererInjectedProps>;
-
/**
-
* Fallback content rendered before any request completes.
-
*/
-
fallback?: React.ReactNode;
-
/**
-
* Loading indicator rendered while the parent post is resolving.
-
*/
-
loadingIndicator?: React.ReactNode;
-
/**
-
* Controls whether the Bluesky icon is shown. Defaults to `true`.
-
*/
-
showIcon?: boolean;
-
/**
-
* Placement for the Bluesky icon. Defaults to `'timestamp'`.
-
*/
-
iconPlacement?: 'cardBottomRight' | 'timestamp' | 'linkInline';
}
/**
···
* @param iconPlacement - Placement location for the icon. Defaults to `'timestamp'`.
* @returns A `BlueskyPost` element configured with an augmented renderer.
*/
-
const BlueskyQuotePostComponent: React.FC<BlueskyQuotePostProps> = ({ did, rkey, colorScheme, renderer, fallback, loadingIndicator, showIcon = true, iconPlacement = 'timestamp' }) => {
-
const BaseRenderer = renderer ?? BlueskyPostRenderer;
-
const Renderer = useMemo(() => {
-
const QuoteRenderer: React.FC<BlueskyPostRendererInjectedProps> = (props) => {
-
const resolvedColorScheme = props.colorScheme ?? colorScheme;
-
const embedSource = props.record.embed as QuoteRecordEmbed | undefined;
-
const embedNode = useMemo(
-
() => createQuoteEmbed(embedSource, resolvedColorScheme),
-
[embedSource, resolvedColorScheme]
-
);
-
return <BaseRenderer {...props} embed={embedNode} />;
-
};
-
QuoteRenderer.displayName = 'BlueskyQuotePostRenderer';
-
const MemoizedQuoteRenderer = memo(QuoteRenderer);
-
MemoizedQuoteRenderer.displayName = 'BlueskyQuotePostRenderer';
-
return MemoizedQuoteRenderer;
-
}, [BaseRenderer, colorScheme]);
-
return (
-
<BlueskyPost
-
did={did}
-
rkey={rkey}
-
colorScheme={colorScheme}
-
renderer={Renderer}
-
fallback={fallback}
-
loadingIndicator={loadingIndicator}
-
showIcon={showIcon}
-
iconPlacement={iconPlacement}
-
/>
-
);
};
-
BlueskyQuotePostComponent.displayName = 'BlueskyQuotePost';
-
export const BlueskyQuotePost: NamedExoticComponent<BlueskyQuotePostProps> = memo(BlueskyQuotePostComponent);
-
BlueskyQuotePost.displayName = 'BlueskyQuotePost';
/**
* Builds the quoted post embed node when the parent record contains a record embed.
···
*/
type QuoteRecordEmbed = { $type?: string; record?: { uri?: string } };
-
function createQuoteEmbed(embed: QuoteRecordEmbed | undefined, colorScheme?: 'light' | 'dark' | 'system') {
-
if (!embed || embed.$type !== 'app.bsky.embed.record') return null;
-
const quoted = embed.record;
-
const quotedUri = quoted?.uri;
-
const parsed = parseAtUri(quotedUri);
-
if (!parsed || parsed.collection !== BLUESKY_POST_COLLECTION) return null;
-
return (
-
<div style={quoteWrapperStyle}>
-
<BlueskyPost did={parsed.did} rkey={parsed.rkey} colorScheme={colorScheme} showIcon={false} />
-
</div>
-
);
}
const quoteWrapperStyle: React.CSSProperties = {
-
display: 'flex',
-
flexDirection: 'column',
-
gap: 8
};
export default BlueskyQuotePost;
···
+
import React, { memo, useMemo, type NamedExoticComponent } from "react";
+
import {
+
BlueskyPost,
+
type BlueskyPostRendererInjectedProps,
+
BLUESKY_POST_COLLECTION,
+
} from "./BlueskyPost";
+
import { BlueskyPostRenderer } from "../renderers/BlueskyPostRenderer";
+
import { parseAtUri } from "../utils/at-uri";
/**
* Props for rendering a Bluesky post that quotes another Bluesky post.
*/
export interface BlueskyQuotePostProps {
+
/**
+
* DID of the repository that owns the parent post.
+
*/
+
did: string;
+
/**
+
* Record key of the parent post.
+
*/
+
rkey: string;
+
/**
+
* Preferred color scheme propagated to nested renders.
+
*/
+
colorScheme?: "light" | "dark" | "system";
+
/**
+
* Custom renderer override applied to the parent post.
+
*/
+
renderer?: React.ComponentType<BlueskyPostRendererInjectedProps>;
+
/**
+
* Fallback content rendered before any request completes.
+
*/
+
fallback?: React.ReactNode;
+
/**
+
* Loading indicator rendered while the parent post is resolving.
+
*/
+
loadingIndicator?: React.ReactNode;
+
/**
+
* Controls whether the Bluesky icon is shown. Defaults to `true`.
+
*/
+
showIcon?: boolean;
+
/**
+
* Placement for the Bluesky icon. Defaults to `'timestamp'`.
+
*/
+
iconPlacement?: "cardBottomRight" | "timestamp" | "linkInline";
}
/**
···
* @param iconPlacement - Placement location for the icon. Defaults to `'timestamp'`.
* @returns A `BlueskyPost` element configured with an augmented renderer.
*/
+
const BlueskyQuotePostComponent: React.FC<BlueskyQuotePostProps> = ({
+
did,
+
rkey,
+
colorScheme,
+
renderer,
+
fallback,
+
loadingIndicator,
+
showIcon = true,
+
iconPlacement = "timestamp",
+
}) => {
+
const BaseRenderer = renderer ?? BlueskyPostRenderer;
+
const Renderer = useMemo(() => {
+
const QuoteRenderer: React.FC<BlueskyPostRendererInjectedProps> = (
+
props,
+
) => {
+
const resolvedColorScheme = props.colorScheme ?? colorScheme;
+
const embedSource = props.record.embed as
+
| QuoteRecordEmbed
+
| undefined;
+
const embedNode = useMemo(
+
() => createQuoteEmbed(embedSource, resolvedColorScheme),
+
[embedSource, resolvedColorScheme],
+
);
+
return <BaseRenderer {...props} embed={embedNode} />;
+
};
+
QuoteRenderer.displayName = "BlueskyQuotePostRenderer";
+
const MemoizedQuoteRenderer = memo(QuoteRenderer);
+
MemoizedQuoteRenderer.displayName = "BlueskyQuotePostRenderer";
+
return MemoizedQuoteRenderer;
+
}, [BaseRenderer, colorScheme]);
+
return (
+
<BlueskyPost
+
did={did}
+
rkey={rkey}
+
colorScheme={colorScheme}
+
renderer={Renderer}
+
fallback={fallback}
+
loadingIndicator={loadingIndicator}
+
showIcon={showIcon}
+
iconPlacement={iconPlacement}
+
/>
+
);
};
+
BlueskyQuotePostComponent.displayName = "BlueskyQuotePost";
+
export const BlueskyQuotePost: NamedExoticComponent<BlueskyQuotePostProps> =
+
memo(BlueskyQuotePostComponent);
+
BlueskyQuotePost.displayName = "BlueskyQuotePost";
/**
* Builds the quoted post embed node when the parent record contains a record embed.
···
*/
type QuoteRecordEmbed = { $type?: string; record?: { uri?: string } };
+
function createQuoteEmbed(
+
embed: QuoteRecordEmbed | undefined,
+
colorScheme?: "light" | "dark" | "system",
+
) {
+
if (!embed || embed.$type !== "app.bsky.embed.record") return null;
+
const quoted = embed.record;
+
const quotedUri = quoted?.uri;
+
const parsed = parseAtUri(quotedUri);
+
if (!parsed || parsed.collection !== BLUESKY_POST_COLLECTION) return null;
+
return (
+
<div style={quoteWrapperStyle}>
+
<BlueskyPost
+
did={parsed.did}
+
rkey={parsed.rkey}
+
colorScheme={colorScheme}
+
showIcon={false}
+
/>
+
</div>
+
);
}
const quoteWrapperStyle: React.CSSProperties = {
+
display: "flex",
+
flexDirection: "column",
+
gap: 8,
};
export default BlueskyQuotePost;
+96 -83
lib/components/ColorSchemeToggle.tsx
···
-
import React from 'react';
-
import type { ColorSchemePreference } from '../hooks/useColorScheme';
/**
* Props for the `ColorSchemeToggle` segmented control.
*/
export interface ColorSchemeToggleProps {
-
/**
-
* Current color scheme preference selection.
-
*/
-
value: ColorSchemePreference;
-
/**
-
* Change handler invoked when the user selects a new scheme.
-
*/
-
onChange: (value: ColorSchemePreference) => void;
-
/**
-
* Theme used to style the control itself; defaults to `'light'`.
-
*/
-
scheme?: 'light' | 'dark';
}
-
const options: Array<{ label: string; value: ColorSchemePreference; description: string }> = [
-
{ label: 'System', value: 'system', description: 'Follow OS preference' },
-
{ label: 'Light', value: 'light', description: 'Always light mode' },
-
{ label: 'Dark', value: 'dark', description: 'Always dark mode' }
];
/**
···
* @param scheme - Theme used to style the control itself. Defaults to `'light'`.
* @returns A fully keyboard-accessible toggle rendered as a radio group.
*/
-
export const ColorSchemeToggle: React.FC<ColorSchemeToggleProps> = ({ value, onChange, scheme = 'light' }) => {
-
const palette = scheme === 'dark' ? darkTheme : lightTheme;
-
return (
-
<div aria-label="Color scheme" role="radiogroup" style={{ ...containerStyle, ...palette.container }}>
-
{options.map(option => {
-
const isActive = option.value === value;
-
const activeStyles = isActive ? palette.active : undefined;
-
return (
-
<button
-
key={option.value}
-
role="radio"
-
aria-checked={isActive}
-
type="button"
-
onClick={() => onChange(option.value)}
-
style={{
-
...buttonStyle,
-
...palette.button,
-
...(activeStyles ?? {})
-
}}
-
title={option.description}
-
>
-
{option.label}
-
</button>
-
);
-
})}
-
</div>
-
);
};
const containerStyle: React.CSSProperties = {
-
display: 'inline-flex',
-
borderRadius: 999,
-
padding: 4,
-
gap: 4,
-
border: '1px solid transparent',
-
background: '#f8fafc'
};
const buttonStyle: React.CSSProperties = {
-
border: '1px solid transparent',
-
borderRadius: 999,
-
padding: '4px 12px',
-
fontSize: 12,
-
fontWeight: 500,
-
cursor: 'pointer',
-
background: 'transparent',
-
transition: 'background-color 160ms ease, border-color 160ms ease, color 160ms ease'
};
const lightTheme = {
-
container: {
-
borderColor: '#e2e8f0',
-
background: 'rgba(241, 245, 249, 0.8)'
-
},
-
button: {
-
color: '#334155'
-
},
-
active: {
-
background: '#2563eb',
-
borderColor: '#2563eb',
-
color: '#f8fafc'
-
}
} satisfies Record<string, React.CSSProperties>;
const darkTheme = {
-
container: {
-
borderColor: '#2e3540ff',
-
background: 'rgba(30, 38, 49, 0.6)'
-
},
-
button: {
-
color: '#e2e8f0'
-
},
-
active: {
-
background: '#38bdf8',
-
borderColor: '#38bdf8',
-
color: '#020617'
-
}
} satisfies Record<string, React.CSSProperties>;
export default ColorSchemeToggle;
···
+
import React from "react";
+
import type { ColorSchemePreference } from "../hooks/useColorScheme";
/**
* Props for the `ColorSchemeToggle` segmented control.
*/
export interface ColorSchemeToggleProps {
+
/**
+
* Current color scheme preference selection.
+
*/
+
value: ColorSchemePreference;
+
/**
+
* Change handler invoked when the user selects a new scheme.
+
*/
+
onChange: (value: ColorSchemePreference) => void;
+
/**
+
* Theme used to style the control itself; defaults to `'light'`.
+
*/
+
scheme?: "light" | "dark";
}
+
const options: Array<{
+
label: string;
+
value: ColorSchemePreference;
+
description: string;
+
}> = [
+
{ label: "System", value: "system", description: "Follow OS preference" },
+
{ label: "Light", value: "light", description: "Always light mode" },
+
{ label: "Dark", value: "dark", description: "Always dark mode" },
];
/**
···
* @param scheme - Theme used to style the control itself. Defaults to `'light'`.
* @returns A fully keyboard-accessible toggle rendered as a radio group.
*/
+
export const ColorSchemeToggle: React.FC<ColorSchemeToggleProps> = ({
+
value,
+
onChange,
+
scheme = "light",
+
}) => {
+
const palette = scheme === "dark" ? darkTheme : lightTheme;
+
return (
+
<div
+
aria-label="Color scheme"
+
role="radiogroup"
+
style={{ ...containerStyle, ...palette.container }}
+
>
+
{options.map((option) => {
+
const isActive = option.value === value;
+
const activeStyles = isActive ? palette.active : undefined;
+
return (
+
<button
+
key={option.value}
+
role="radio"
+
aria-checked={isActive}
+
type="button"
+
onClick={() => onChange(option.value)}
+
style={{
+
...buttonStyle,
+
...palette.button,
+
...(activeStyles ?? {}),
+
}}
+
title={option.description}
+
>
+
{option.label}
+
</button>
+
);
+
})}
+
</div>
+
);
};
const containerStyle: React.CSSProperties = {
+
display: "inline-flex",
+
borderRadius: 999,
+
padding: 4,
+
gap: 4,
+
border: "1px solid transparent",
+
background: "#f8fafc",
};
const buttonStyle: React.CSSProperties = {
+
border: "1px solid transparent",
+
borderRadius: 999,
+
padding: "4px 12px",
+
fontSize: 12,
+
fontWeight: 500,
+
cursor: "pointer",
+
background: "transparent",
+
transition:
+
"background-color 160ms ease, border-color 160ms ease, color 160ms ease",
};
const lightTheme = {
+
container: {
+
borderColor: "#e2e8f0",
+
background: "rgba(241, 245, 249, 0.8)",
+
},
+
button: {
+
color: "#334155",
+
},
+
active: {
+
background: "#2563eb",
+
borderColor: "#2563eb",
+
color: "#f8fafc",
+
},
} satisfies Record<string, React.CSSProperties>;
const darkTheme = {
+
container: {
+
borderColor: "#2e3540ff",
+
background: "rgba(30, 38, 49, 0.6)",
+
},
+
button: {
+
color: "#e2e8f0",
+
},
+
active: {
+
background: "#38bdf8",
+
borderColor: "#38bdf8",
+
color: "#020617",
+
},
} satisfies Record<string, React.CSSProperties>;
export default ColorSchemeToggle;
+115 -75
lib/components/LeafletDocument.tsx
···
-
import React, { useMemo } from 'react';
-
import { AtProtoRecord } from '../core/AtProtoRecord';
-
import { LeafletDocumentRenderer, type LeafletDocumentRendererProps } from '../renderers/LeafletDocumentRenderer';
-
import type { LeafletDocumentRecord, LeafletPublicationRecord } from '../types/leaflet';
-
import type { ColorSchemePreference } from '../hooks/useColorScheme';
-
import { parseAtUri, toBlueskyPostUrl, leafletRkeyUrl, normalizeLeafletBasePath } from '../utils/at-uri';
-
import { useAtProtoRecord } from '../hooks/useAtProtoRecord';
/**
* Props for rendering a Leaflet document record.
*/
export interface LeafletDocumentProps {
-
/**
-
* DID of the Leaflet publisher.
-
*/
-
did: string;
-
/**
-
* Record key of the document within the Leaflet collection.
-
*/
-
rkey: string;
-
/**
-
* Optional custom renderer for advanced layouts.
-
*/
-
renderer?: React.ComponentType<LeafletDocumentRendererInjectedProps>;
-
/**
-
* React node rendered before data begins loading.
-
*/
-
fallback?: React.ReactNode;
-
/**
-
* Indicator rendered while data is being fetched from the PDS.
-
*/
-
loadingIndicator?: React.ReactNode;
-
/**
-
* Preferred color scheme to forward to the renderer.
-
*/
-
colorScheme?: ColorSchemePreference;
}
/**
···
export type LeafletDocumentRendererInjectedProps = LeafletDocumentRendererProps;
/** NSID for Leaflet document records. */
-
export const LEAFLET_DOCUMENT_COLLECTION = 'pub.leaflet.document';
/**
* Loads a Leaflet document along with its associated publication record and renders it
···
* @param colorScheme - Preferred color scheme forwarded to the renderer.
* @returns A JSX subtree that renders a Leaflet document with contextual metadata.
*/
-
export const LeafletDocument: React.FC<LeafletDocumentProps> = ({ did, rkey, renderer, fallback, loadingIndicator, colorScheme }) => {
-
const Comp: React.ComponentType<LeafletDocumentRendererInjectedProps> = renderer ?? ((props) => <LeafletDocumentRenderer {...props} />);
-
const Wrapped: React.FC<{ record: LeafletDocumentRecord; loading: boolean; error?: Error }> = (props) => {
-
const publicationUri = useMemo(() => parseAtUri(props.record.publication), [props.record.publication]);
-
const { record: publicationRecord } = useAtProtoRecord<LeafletPublicationRecord>({
-
did: publicationUri?.did,
-
collection: publicationUri?.collection ?? 'pub.leaflet.publication',
-
rkey: publicationUri?.rkey ?? ''
-
});
-
const publicationBaseUrl = normalizeLeafletBasePath(publicationRecord?.base_path);
-
const canonicalUrl = resolveCanonicalUrl(props.record, did, rkey, publicationRecord?.base_path);
-
return (
-
<Comp
-
{...props}
-
colorScheme={colorScheme}
-
did={did}
-
rkey={rkey}
-
canonicalUrl={canonicalUrl}
-
publicationBaseUrl={publicationBaseUrl}
-
publicationRecord={publicationRecord}
-
/>
-
);
-
};
-
return (
-
<AtProtoRecord<LeafletDocumentRecord>
-
did={did}
-
collection={LEAFLET_DOCUMENT_COLLECTION}
-
rkey={rkey}
-
renderer={Wrapped}
-
fallback={fallback}
-
loadingIndicator={loadingIndicator}
-
/>
-
);
};
/**
···
* @param publicationBasePath - Optional base path configured by the publication.
* @returns A URL to use for canonical links.
*/
-
function resolveCanonicalUrl(record: LeafletDocumentRecord, did: string, rkey: string, publicationBasePath?: string): string {
-
const publicationUrl = leafletRkeyUrl(publicationBasePath, rkey);
-
if (publicationUrl) return publicationUrl;
-
const postUri = record.postRef?.uri;
-
if (postUri) {
-
const parsed = parseAtUri(postUri);
-
const href = parsed ? toBlueskyPostUrl(parsed) : undefined;
-
if (href) return href;
-
}
-
return `at://${encodeURIComponent(did)}/${LEAFLET_DOCUMENT_COLLECTION}/${encodeURIComponent(rkey)}`;
}
export default LeafletDocument;
···
+
import React, { useMemo } from "react";
+
import { AtProtoRecord } from "../core/AtProtoRecord";
+
import {
+
LeafletDocumentRenderer,
+
type LeafletDocumentRendererProps,
+
} from "../renderers/LeafletDocumentRenderer";
+
import type {
+
LeafletDocumentRecord,
+
LeafletPublicationRecord,
+
} from "../types/leaflet";
+
import type { ColorSchemePreference } from "../hooks/useColorScheme";
+
import {
+
parseAtUri,
+
toBlueskyPostUrl,
+
leafletRkeyUrl,
+
normalizeLeafletBasePath,
+
} from "../utils/at-uri";
+
import { useAtProtoRecord } from "../hooks/useAtProtoRecord";
/**
* Props for rendering a Leaflet document record.
*/
export interface LeafletDocumentProps {
+
/**
+
* DID of the Leaflet publisher.
+
*/
+
did: string;
+
/**
+
* Record key of the document within the Leaflet collection.
+
*/
+
rkey: string;
+
/**
+
* Optional custom renderer for advanced layouts.
+
*/
+
renderer?: React.ComponentType<LeafletDocumentRendererInjectedProps>;
+
/**
+
* React node rendered before data begins loading.
+
*/
+
fallback?: React.ReactNode;
+
/**
+
* Indicator rendered while data is being fetched from the PDS.
+
*/
+
loadingIndicator?: React.ReactNode;
+
/**
+
* Preferred color scheme to forward to the renderer.
+
*/
+
colorScheme?: ColorSchemePreference;
}
/**
···
export type LeafletDocumentRendererInjectedProps = LeafletDocumentRendererProps;
/** NSID for Leaflet document records. */
+
export const LEAFLET_DOCUMENT_COLLECTION = "pub.leaflet.document";
/**
* Loads a Leaflet document along with its associated publication record and renders it
···
* @param colorScheme - Preferred color scheme forwarded to the renderer.
* @returns A JSX subtree that renders a Leaflet document with contextual metadata.
*/
+
export const LeafletDocument: React.FC<LeafletDocumentProps> = ({
+
did,
+
rkey,
+
renderer,
+
fallback,
+
loadingIndicator,
+
colorScheme,
+
}) => {
+
const Comp: React.ComponentType<LeafletDocumentRendererInjectedProps> =
+
renderer ?? ((props) => <LeafletDocumentRenderer {...props} />);
+
const Wrapped: React.FC<{
+
record: LeafletDocumentRecord;
+
loading: boolean;
+
error?: Error;
+
}> = (props) => {
+
const publicationUri = useMemo(
+
() => parseAtUri(props.record.publication),
+
[props.record.publication],
+
);
+
const { record: publicationRecord } =
+
useAtProtoRecord<LeafletPublicationRecord>({
+
did: publicationUri?.did,
+
collection:
+
publicationUri?.collection ?? "pub.leaflet.publication",
+
rkey: publicationUri?.rkey ?? "",
+
});
+
const publicationBaseUrl = normalizeLeafletBasePath(
+
publicationRecord?.base_path,
+
);
+
const canonicalUrl = resolveCanonicalUrl(
+
props.record,
+
did,
+
rkey,
+
publicationRecord?.base_path,
+
);
+
return (
+
<Comp
+
{...props}
+
colorScheme={colorScheme}
+
did={did}
+
rkey={rkey}
+
canonicalUrl={canonicalUrl}
+
publicationBaseUrl={publicationBaseUrl}
+
publicationRecord={publicationRecord}
+
/>
+
);
+
};
+
return (
+
<AtProtoRecord<LeafletDocumentRecord>
+
did={did}
+
collection={LEAFLET_DOCUMENT_COLLECTION}
+
rkey={rkey}
+
renderer={Wrapped}
+
fallback={fallback}
+
loadingIndicator={loadingIndicator}
+
/>
+
);
};
/**
···
* @param publicationBasePath - Optional base path configured by the publication.
* @returns A URL to use for canonical links.
*/
+
function resolveCanonicalUrl(
+
record: LeafletDocumentRecord,
+
did: string,
+
rkey: string,
+
publicationBasePath?: string,
+
): string {
+
const publicationUrl = leafletRkeyUrl(publicationBasePath, rkey);
+
if (publicationUrl) return publicationUrl;
+
const postUri = record.postRef?.uri;
+
if (postUri) {
+
const parsed = parseAtUri(postUri);
+
const href = parsed ? toBlueskyPostUrl(parsed) : undefined;
+
if (href) return href;
+
}
+
return `at://${encodeURIComponent(did)}/${LEAFLET_DOCUMENT_COLLECTION}/${encodeURIComponent(rkey)}`;
}
export default LeafletDocument;
+22 -10
lib/components/TangledString.tsx
···
-
import React from 'react';
-
import { AtProtoRecord } from '../core/AtProtoRecord';
-
import { TangledStringRenderer } from '../renderers/TangledStringRenderer';
-
import type { TangledStringRecord } from '../renderers/TangledStringRenderer';
/**
* Props for rendering Tangled String records.
···
/** Indicator node shown while data is loading. */
loadingIndicator?: React.ReactNode;
/** Preferred color scheme for theming. */
-
colorScheme?: 'light' | 'dark' | 'system';
}
/**
···
/** Fetch error, if any. */
error?: Error;
/** Preferred color scheme for downstream components. */
-
colorScheme?: 'light' | 'dark' | 'system';
/** DID associated with the record. */
did: string;
/** Record key for the string. */
···
};
/** NSID for Tangled String records. */
-
export const TANGLED_COLLECTION = 'sh.tangled.string';
/**
* Resolves a Tangled String record and renders it with optional overrides while computing a canonical link.
···
* @param colorScheme - Preferred color scheme for theming the renderer.
* @returns A JSX subtree representing the Tangled String record with loading states handled.
*/
-
export const TangledString: React.FC<TangledStringProps> = ({ did, rkey, renderer, fallback, loadingIndicator, colorScheme }) => {
-
const Comp: React.ComponentType<TangledStringRendererInjectedProps> = renderer ?? ((props) => <TangledStringRenderer {...props} />);
-
const Wrapped: React.FC<{ record: TangledStringRecord; loading: boolean; error?: Error }> = (props) => (
<Comp
{...props}
colorScheme={colorScheme}
···
+
import React from "react";
+
import { AtProtoRecord } from "../core/AtProtoRecord";
+
import { TangledStringRenderer } from "../renderers/TangledStringRenderer";
+
import type { TangledStringRecord } from "../renderers/TangledStringRenderer";
/**
* Props for rendering Tangled String records.
···
/** Indicator node shown while data is loading. */
loadingIndicator?: React.ReactNode;
/** Preferred color scheme for theming. */
+
colorScheme?: "light" | "dark" | "system";
}
/**
···
/** Fetch error, if any. */
error?: Error;
/** Preferred color scheme for downstream components. */
+
colorScheme?: "light" | "dark" | "system";
/** DID associated with the record. */
did: string;
/** Record key for the string. */
···
};
/** NSID for Tangled String records. */
+
export const TANGLED_COLLECTION = "sh.tangled.string";
/**
* Resolves a Tangled String record and renders it with optional overrides while computing a canonical link.
···
* @param colorScheme - Preferred color scheme for theming the renderer.
* @returns A JSX subtree representing the Tangled String record with loading states handled.
*/
+
export const TangledString: React.FC<TangledStringProps> = ({
+
did,
+
rkey,
+
renderer,
+
fallback,
+
loadingIndicator,
+
colorScheme,
+
}) => {
+
const Comp: React.ComponentType<TangledStringRendererInjectedProps> =
+
renderer ?? ((props) => <TangledStringRenderer {...props} />);
+
const Wrapped: React.FC<{
+
record: TangledStringRecord;
+
loading: boolean;
+
error?: Error;
+
}> = (props) => (
<Comp
{...props}
colorScheme={colorScheme}
+35 -9
lib/core/AtProtoRecord.tsx
···
-
import React from 'react';
-
import { useAtProtoRecord } from '../hooks/useAtProtoRecord';
interface AtProtoRecordRenderProps<T> {
-
renderer?: React.ComponentType<{ record: T; loading: boolean; error?: Error }>;
fallback?: React.ReactNode;
loadingIndicator?: React.ReactNode;
}
···
rkey?: string;
};
-
export type AtProtoRecordProps<T = unknown> = AtProtoRecordFetchProps<T> | AtProtoRecordProvidedRecordProps<T>;
export function AtProtoRecord<T = unknown>(props: AtProtoRecordProps<T>) {
-
const { renderer: Renderer, fallback = null, loadingIndicator = 'Loading…' } = props;
-
const hasProvidedRecord = 'record' in props;
const providedRecord = hasProvidedRecord ? props.record : undefined;
-
const { record: fetchedRecord, error, loading } = useAtProtoRecord<T>({
did: hasProvidedRecord ? undefined : props.did,
collection: hasProvidedRecord ? undefined : props.collection,
rkey: hasProvidedRecord ? undefined : props.rkey,
···
if (error && !record) return <>{fallback}</>;
if (!record) return <>{isLoading ? loadingIndicator : fallback}</>;
-
if (Renderer) return <Renderer record={record} loading={isLoading} error={error} />;
-
return <pre style={{ fontSize: 12, padding: 8, background: '#f5f5f5', overflow: 'auto' }}>{JSON.stringify(record, null, 2)}</pre>;
}
···
+
import React from "react";
+
import { useAtProtoRecord } from "../hooks/useAtProtoRecord";
interface AtProtoRecordRenderProps<T> {
+
renderer?: React.ComponentType<{
+
record: T;
+
loading: boolean;
+
error?: Error;
+
}>;
fallback?: React.ReactNode;
loadingIndicator?: React.ReactNode;
}
···
rkey?: string;
};
+
export type AtProtoRecordProps<T = unknown> =
+
| AtProtoRecordFetchProps<T>
+
| AtProtoRecordProvidedRecordProps<T>;
export function AtProtoRecord<T = unknown>(props: AtProtoRecordProps<T>) {
+
const {
+
renderer: Renderer,
+
fallback = null,
+
loadingIndicator = "Loading…",
+
} = props;
+
const hasProvidedRecord = "record" in props;
const providedRecord = hasProvidedRecord ? props.record : undefined;
+
const {
+
record: fetchedRecord,
+
error,
+
loading,
+
} = useAtProtoRecord<T>({
did: hasProvidedRecord ? undefined : props.did,
collection: hasProvidedRecord ? undefined : props.collection,
rkey: hasProvidedRecord ? undefined : props.rkey,
···
if (error && !record) return <>{fallback}</>;
if (!record) return <>{isLoading ? loadingIndicator : fallback}</>;
+
if (Renderer)
+
return <Renderer record={record} loading={isLoading} error={error} />;
+
return (
+
<pre
+
style={{
+
fontSize: 12,
+
padding: 8,
+
background: "#f5f5f5",
+
overflow: "auto",
+
}}
+
>
+
{JSON.stringify(record, null, 2)}
+
</pre>
+
);
}
+113 -67
lib/hooks/useAtProtoRecord.ts
···
-
import { useEffect, useState } from 'react';
-
import { useDidResolution } from './useDidResolution';
-
import { usePdsEndpoint } from './usePdsEndpoint';
-
import { createAtprotoClient } from '../utils/atproto-client';
/**
* Identifier trio required to address an AT Protocol record.
*/
export interface AtProtoRecordKey {
-
/** Repository DID (or handle prior to resolution) containing the record. */
-
did?: string;
-
/** NSID collection in which the record resides. */
-
collection?: string;
-
/** Record key string uniquely identifying the record within the collection. */
-
rkey?: string;
}
/**
* Loading state returned by {@link useAtProtoRecord}.
*/
export interface AtProtoRecordState<T = unknown> {
-
/** Resolved record value when fetch succeeds. */
-
record?: T;
-
/** Error thrown while loading, if any. */
-
error?: Error;
-
/** Indicates whether the hook is in a loading state. */
-
loading: boolean;
}
/**
···
* @param rkey - Record key identifying the record within the collection.
* @returns {AtProtoRecordState<T>} Object containing the resolved record, any error, and a loading flag.
*/
-
export function useAtProtoRecord<T = unknown>({ did: handleOrDid, collection, rkey }: AtProtoRecordKey): AtProtoRecordState<T> {
-
const { did, error: didError, loading: resolvingDid } = useDidResolution(handleOrDid);
-
const { endpoint, error: endpointError, loading: resolvingEndpoint } = usePdsEndpoint(did);
-
const [state, setState] = useState<AtProtoRecordState<T>>({ loading: !!(handleOrDid && collection && rkey) });
-
useEffect(() => {
-
let cancelled = false;
-
const assignState = (next: Partial<AtProtoRecordState<T>>) => {
-
if (cancelled) return;
-
setState(prev => ({ ...prev, ...next }));
-
};
-
if (!handleOrDid || !collection || !rkey) {
-
assignState({ loading: false, record: undefined, error: undefined });
-
return () => { cancelled = true; };
-
}
-
if (didError) {
-
assignState({ loading: false, error: didError });
-
return () => { cancelled = true; };
-
}
-
if (endpointError) {
-
assignState({ loading: false, error: endpointError });
-
return () => { cancelled = true; };
-
}
-
if (resolvingDid || resolvingEndpoint || !did || !endpoint) {
-
assignState({ loading: true, error: undefined });
-
return () => { cancelled = true; };
-
}
-
assignState({ loading: true, error: undefined, record: undefined });
-
(async () => {
-
try {
-
const { rpc } = await createAtprotoClient({ service: endpoint });
-
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', {
-
params: { repo: did, collection, rkey }
-
});
-
if (!res.ok) throw new Error('Failed to load record');
-
const record = (res.data as { value: T }).value;
-
assignState({ record, loading: false });
-
} catch (e) {
-
const err = e instanceof Error ? e : new Error(String(e));
-
assignState({ error: err, loading: false });
-
}
-
})();
-
return () => {
-
cancelled = true;
-
};
-
}, [handleOrDid, did, endpoint, collection, rkey, resolvingDid, resolvingEndpoint, didError, endpointError]);
-
return state;
}
···
+
import { useEffect, useState } from "react";
+
import { useDidResolution } from "./useDidResolution";
+
import { usePdsEndpoint } from "./usePdsEndpoint";
+
import { createAtprotoClient } from "../utils/atproto-client";
/**
* Identifier trio required to address an AT Protocol record.
*/
export interface AtProtoRecordKey {
+
/** Repository DID (or handle prior to resolution) containing the record. */
+
did?: string;
+
/** NSID collection in which the record resides. */
+
collection?: string;
+
/** Record key string uniquely identifying the record within the collection. */
+
rkey?: string;
}
/**
* Loading state returned by {@link useAtProtoRecord}.
*/
export interface AtProtoRecordState<T = unknown> {
+
/** Resolved record value when fetch succeeds. */
+
record?: T;
+
/** Error thrown while loading, if any. */
+
error?: Error;
+
/** Indicates whether the hook is in a loading state. */
+
loading: boolean;
}
/**
···
* @param rkey - Record key identifying the record within the collection.
* @returns {AtProtoRecordState<T>} Object containing the resolved record, any error, and a loading flag.
*/
+
export function useAtProtoRecord<T = unknown>({
+
did: handleOrDid,
+
collection,
+
rkey,
+
}: AtProtoRecordKey): AtProtoRecordState<T> {
+
const {
+
did,
+
error: didError,
+
loading: resolvingDid,
+
} = useDidResolution(handleOrDid);
+
const {
+
endpoint,
+
error: endpointError,
+
loading: resolvingEndpoint,
+
} = usePdsEndpoint(did);
+
const [state, setState] = useState<AtProtoRecordState<T>>({
+
loading: !!(handleOrDid && collection && rkey),
+
});
+
useEffect(() => {
+
let cancelled = false;
+
const assignState = (next: Partial<AtProtoRecordState<T>>) => {
+
if (cancelled) return;
+
setState((prev) => ({ ...prev, ...next }));
+
};
+
if (!handleOrDid || !collection || !rkey) {
+
assignState({
+
loading: false,
+
record: undefined,
+
error: undefined,
+
});
+
return () => {
+
cancelled = true;
+
};
+
}
+
if (didError) {
+
assignState({ loading: false, error: didError });
+
return () => {
+
cancelled = true;
+
};
+
}
+
if (endpointError) {
+
assignState({ loading: false, error: endpointError });
+
return () => {
+
cancelled = true;
+
};
+
}
+
if (resolvingDid || resolvingEndpoint || !did || !endpoint) {
+
assignState({ loading: true, error: undefined });
+
return () => {
+
cancelled = true;
+
};
+
}
+
assignState({ loading: true, error: undefined, record: undefined });
+
(async () => {
+
try {
+
const { rpc } = await createAtprotoClient({
+
service: endpoint,
+
});
+
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", {
+
params: { repo: did, collection, rkey },
+
});
+
if (!res.ok) throw new Error("Failed to load record");
+
const record = (res.data as { value: T }).value;
+
assignState({ record, loading: false });
+
} catch (e) {
+
const err = e instanceof Error ? e : new Error(String(e));
+
assignState({ error: err, loading: false });
+
}
+
})();
+
return () => {
+
cancelled = true;
+
};
+
}, [
+
handleOrDid,
+
did,
+
endpoint,
+
collection,
+
rkey,
+
resolvingDid,
+
resolvingEndpoint,
+
didError,
+
endpointError,
+
]);
+
return state;
}
+148 -109
lib/hooks/useBlob.ts
···
-
import { useEffect, useRef, useState } from 'react';
-
import { useDidResolution } from './useDidResolution';
-
import { usePdsEndpoint } from './usePdsEndpoint';
-
import { useAtProto } from '../providers/AtProtoProvider';
/**
* Status returned by {@link useBlob} containing blob URL and metadata flags.
*/
export interface UseBlobState {
-
/** Object URL pointing to the fetched blob, when available. */
-
url?: string;
-
/** Indicates whether a fetch is in progress. */
-
loading: boolean;
-
/** Error encountered while fetching the blob. */
-
error?: Error;
}
/**
···
* @param cid - Content identifier for the desired blob.
* @returns {UseBlobState} Object containing the object URL, loading flag, and any error.
*/
-
export function useBlob(handleOrDid: string | undefined, cid: string | undefined): UseBlobState {
-
const { did, error: didError, loading: didLoading } = useDidResolution(handleOrDid);
-
const { endpoint, error: endpointError, loading: endpointLoading } = usePdsEndpoint(did);
-
const { blobCache } = useAtProto();
-
const [state, setState] = useState<UseBlobState>({ loading: !!(handleOrDid && cid) });
-
const objectUrlRef = useRef<string | undefined>(undefined);
-
useEffect(() => () => {
-
if (objectUrlRef.current) {
-
URL.revokeObjectURL(objectUrlRef.current);
-
objectUrlRef.current = undefined;
-
}
-
}, []);
-
useEffect(() => {
-
let cancelled = false;
-
const clearObjectUrl = () => {
-
if (objectUrlRef.current) {
-
URL.revokeObjectURL(objectUrlRef.current);
-
objectUrlRef.current = undefined;
-
}
-
};
-
if (!handleOrDid || !cid) {
-
clearObjectUrl();
-
setState({ loading: false });
-
return () => {
-
cancelled = true;
-
};
-
}
-
if (didError) {
-
clearObjectUrl();
-
setState({ loading: false, error: didError });
-
return () => {
-
cancelled = true;
-
};
-
}
-
if (endpointError) {
-
clearObjectUrl();
-
setState({ loading: false, error: endpointError });
-
return () => {
-
cancelled = true;
-
};
-
}
-
if (didLoading || endpointLoading || !did || !endpoint) {
-
setState(prev => ({ ...prev, loading: true, error: undefined }));
-
return () => {
-
cancelled = true;
-
};
-
}
-
const cachedBlob = blobCache.get(did, cid);
-
if (cachedBlob) {
-
const nextUrl = URL.createObjectURL(cachedBlob);
-
const prevUrl = objectUrlRef.current;
-
objectUrlRef.current = nextUrl;
-
if (prevUrl) URL.revokeObjectURL(prevUrl);
-
setState({ url: nextUrl, loading: false });
-
return () => {
-
cancelled = true;
-
};
-
}
-
let controller: AbortController | undefined;
-
let release: (() => void) | undefined;
-
(async () => {
-
try {
-
setState(prev => ({ ...prev, loading: true, error: undefined }));
-
const ensureResult = blobCache.ensure(did, cid, () => {
-
controller = new AbortController();
-
const promise = (async () => {
-
const res = await fetch(
-
`${endpoint}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`,
-
{ signal: controller?.signal }
-
);
-
if (!res.ok) throw new Error(`Blob fetch failed (${res.status})`);
-
return res.blob();
-
})();
-
return { promise, abort: () => controller?.abort() };
-
});
-
release = ensureResult.release;
-
const blob = await ensureResult.promise;
-
const nextUrl = URL.createObjectURL(blob);
-
const prevUrl = objectUrlRef.current;
-
objectUrlRef.current = nextUrl;
-
if (prevUrl) URL.revokeObjectURL(prevUrl);
-
if (!cancelled) setState({ url: nextUrl, loading: false });
-
} catch (e) {
-
const aborted = (controller && controller.signal.aborted) || (e instanceof DOMException && e.name === 'AbortError');
-
if (aborted) return;
-
clearObjectUrl();
-
if (!cancelled) setState({ loading: false, error: e as Error });
-
}
-
})();
-
return () => {
-
cancelled = true;
-
release?.();
-
if (controller && controller.signal.aborted && objectUrlRef.current) {
-
URL.revokeObjectURL(objectUrlRef.current);
-
objectUrlRef.current = undefined;
-
}
-
};
-
}, [handleOrDid, cid, did, endpoint, didLoading, endpointLoading, didError, endpointError, blobCache]);
-
return state;
}
···
+
import { useEffect, useRef, useState } from "react";
+
import { useDidResolution } from "./useDidResolution";
+
import { usePdsEndpoint } from "./usePdsEndpoint";
+
import { useAtProto } from "../providers/AtProtoProvider";
/**
* Status returned by {@link useBlob} containing blob URL and metadata flags.
*/
export interface UseBlobState {
+
/** Object URL pointing to the fetched blob, when available. */
+
url?: string;
+
/** Indicates whether a fetch is in progress. */
+
loading: boolean;
+
/** Error encountered while fetching the blob. */
+
error?: Error;
}
/**
···
* @param cid - Content identifier for the desired blob.
* @returns {UseBlobState} Object containing the object URL, loading flag, and any error.
*/
+
export function useBlob(
+
handleOrDid: string | undefined,
+
cid: string | undefined,
+
): UseBlobState {
+
const {
+
did,
+
error: didError,
+
loading: didLoading,
+
} = useDidResolution(handleOrDid);
+
const {
+
endpoint,
+
error: endpointError,
+
loading: endpointLoading,
+
} = usePdsEndpoint(did);
+
const { blobCache } = useAtProto();
+
const [state, setState] = useState<UseBlobState>({
+
loading: !!(handleOrDid && cid),
+
});
+
const objectUrlRef = useRef<string | undefined>(undefined);
+
useEffect(
+
() => () => {
+
if (objectUrlRef.current) {
+
URL.revokeObjectURL(objectUrlRef.current);
+
objectUrlRef.current = undefined;
+
}
+
},
+
[],
+
);
+
useEffect(() => {
+
let cancelled = false;
+
const clearObjectUrl = () => {
+
if (objectUrlRef.current) {
+
URL.revokeObjectURL(objectUrlRef.current);
+
objectUrlRef.current = undefined;
+
}
+
};
+
if (!handleOrDid || !cid) {
+
clearObjectUrl();
+
setState({ loading: false });
+
return () => {
+
cancelled = true;
+
};
+
}
+
if (didError) {
+
clearObjectUrl();
+
setState({ loading: false, error: didError });
+
return () => {
+
cancelled = true;
+
};
+
}
+
if (endpointError) {
+
clearObjectUrl();
+
setState({ loading: false, error: endpointError });
+
return () => {
+
cancelled = true;
+
};
+
}
+
if (didLoading || endpointLoading || !did || !endpoint) {
+
setState((prev) => ({ ...prev, loading: true, error: undefined }));
+
return () => {
+
cancelled = true;
+
};
+
}
+
const cachedBlob = blobCache.get(did, cid);
+
if (cachedBlob) {
+
const nextUrl = URL.createObjectURL(cachedBlob);
+
const prevUrl = objectUrlRef.current;
+
objectUrlRef.current = nextUrl;
+
if (prevUrl) URL.revokeObjectURL(prevUrl);
+
setState({ url: nextUrl, loading: false });
+
return () => {
+
cancelled = true;
+
};
+
}
+
let controller: AbortController | undefined;
+
let release: (() => void) | undefined;
+
(async () => {
+
try {
+
setState((prev) => ({
+
...prev,
+
loading: true,
+
error: undefined,
+
}));
+
const ensureResult = blobCache.ensure(did, cid, () => {
+
controller = new AbortController();
+
const promise = (async () => {
+
const res = await fetch(
+
`${endpoint}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`,
+
{ signal: controller?.signal },
+
);
+
if (!res.ok)
+
throw new Error(
+
`Blob fetch failed (${res.status})`,
+
);
+
return res.blob();
+
})();
+
return { promise, abort: () => controller?.abort() };
+
});
+
release = ensureResult.release;
+
const blob = await ensureResult.promise;
+
const nextUrl = URL.createObjectURL(blob);
+
const prevUrl = objectUrlRef.current;
+
objectUrlRef.current = nextUrl;
+
if (prevUrl) URL.revokeObjectURL(prevUrl);
+
if (!cancelled) setState({ url: nextUrl, loading: false });
+
} catch (e) {
+
const aborted =
+
(controller && controller.signal.aborted) ||
+
(e instanceof DOMException && e.name === "AbortError");
+
if (aborted) return;
+
clearObjectUrl();
+
if (!cancelled) setState({ loading: false, error: e as Error });
+
}
+
})();
+
return () => {
+
cancelled = true;
+
release?.();
+
if (
+
controller &&
+
controller.signal.aborted &&
+
objectUrlRef.current
+
) {
+
URL.revokeObjectURL(objectUrlRef.current);
+
objectUrlRef.current = undefined;
+
}
+
};
+
}, [
+
handleOrDid,
+
cid,
+
did,
+
endpoint,
+
didLoading,
+
endpointLoading,
+
didError,
+
endpointError,
+
blobCache,
+
]);
+
return state;
}
+53 -44
lib/hooks/useBlueskyProfile.ts
···
-
import { useEffect, useState } from 'react';
-
import { usePdsEndpoint } from './usePdsEndpoint';
-
import { createAtprotoClient } from '../utils/atproto-client';
/**
* Minimal profile fields returned by the Bluesky actor profile endpoint.
*/
export interface BlueskyProfileData {
-
/** Actor DID. */
-
did: string;
-
/** Actor handle. */
-
handle: string;
-
/** Display name configured by the actor. */
-
displayName?: string;
-
/** Profile description/bio. */
-
description?: string;
-
/** Avatar blob (CID reference). */
-
avatar?: string;
-
/** Banner image blob (CID reference). */
-
banner?: string;
-
/** Creation timestamp for the profile. */
-
createdAt?: string;
}
/**
···
* @returns {{ data: BlueskyProfileData | undefined; loading: boolean; error: Error | undefined }} Object exposing the profile payload, loading flag, and any error.
*/
export function useBlueskyProfile(did: string | undefined) {
-
const { endpoint } = usePdsEndpoint(did);
-
const [data, setData] = useState<BlueskyProfileData | undefined>();
-
const [loading, setLoading] = useState<boolean>(!!did);
-
const [error, setError] = useState<Error | undefined>();
-
useEffect(() => {
-
let cancelled = false;
-
async function run() {
-
if (!did || !endpoint) return;
-
setLoading(true);
-
try {
-
const { rpc } = await createAtprotoClient({ service: endpoint });
-
const client = rpc as unknown as {
-
get: (nsid: string, options: { params: { actor: string } }) => Promise<{ ok: boolean; data: unknown }>;
-
};
-
const res = await client.get('app.bsky.actor.getProfile', { params: { actor: did } });
-
if (!res.ok) throw new Error('Profile request failed');
-
if (!cancelled) setData(res.data as BlueskyProfileData);
-
} catch (e) {
-
if (!cancelled) setError(e as Error);
-
} finally {
-
if (!cancelled) setLoading(false);
-
}
-
}
-
run();
-
return () => { cancelled = true; };
-
}, [did, endpoint]);
-
return { data, loading, error };
}
···
+
import { useEffect, useState } from "react";
+
import { usePdsEndpoint } from "./usePdsEndpoint";
+
import { createAtprotoClient } from "../utils/atproto-client";
/**
* Minimal profile fields returned by the Bluesky actor profile endpoint.
*/
export interface BlueskyProfileData {
+
/** Actor DID. */
+
did: string;
+
/** Actor handle. */
+
handle: string;
+
/** Display name configured by the actor. */
+
displayName?: string;
+
/** Profile description/bio. */
+
description?: string;
+
/** Avatar blob (CID reference). */
+
avatar?: string;
+
/** Banner image blob (CID reference). */
+
banner?: string;
+
/** Creation timestamp for the profile. */
+
createdAt?: string;
}
/**
···
* @returns {{ data: BlueskyProfileData | undefined; loading: boolean; error: Error | undefined }} Object exposing the profile payload, loading flag, and any error.
*/
export function useBlueskyProfile(did: string | undefined) {
+
const { endpoint } = usePdsEndpoint(did);
+
const [data, setData] = useState<BlueskyProfileData | undefined>();
+
const [loading, setLoading] = useState<boolean>(!!did);
+
const [error, setError] = useState<Error | undefined>();
+
useEffect(() => {
+
let cancelled = false;
+
async function run() {
+
if (!did || !endpoint) return;
+
setLoading(true);
+
try {
+
const { rpc } = await createAtprotoClient({
+
service: endpoint,
+
});
+
const client = rpc as unknown as {
+
get: (
+
nsid: string,
+
options: { params: { actor: string } },
+
) => Promise<{ ok: boolean; data: unknown }>;
+
};
+
const res = await client.get("app.bsky.actor.getProfile", {
+
params: { actor: did },
+
});
+
if (!res.ok) throw new Error("Profile request failed");
+
if (!cancelled) setData(res.data as BlueskyProfileData);
+
} catch (e) {
+
if (!cancelled) setError(e as Error);
+
} finally {
+
if (!cancelled) setLoading(false);
+
}
+
}
+
run();
+
return () => {
+
cancelled = true;
+
};
+
}, [did, endpoint]);
+
return { data, loading, error };
}
+27 -17
lib/hooks/useColorScheme.ts
···
-
import { useEffect, useState } from 'react';
/**
* Possible user-facing color scheme preferences.
*/
-
export type ColorSchemePreference = 'light' | 'dark' | 'system';
-
const MEDIA_QUERY = '(prefers-color-scheme: dark)';
/**
* Resolves a persisted preference into an explicit light/dark value.
···
* @param pref - Stored preference value (`light`, `dark`, or `system`).
* @returns Explicit light/dark scheme suitable for rendering.
*/
-
function resolveScheme(pref: ColorSchemePreference): 'light' | 'dark' {
-
if (pref === 'light' || pref === 'dark') return pref;
-
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
-
return 'light';
}
-
return window.matchMedia(MEDIA_QUERY).matches ? 'dark' : 'light';
}
/**
···
* @param preference - User preference; defaults to following the OS setting.
* @returns {'light' | 'dark'} Explicit scheme that should be used for rendering.
*/
-
export function useColorScheme(preference: ColorSchemePreference = 'system'): 'light' | 'dark' {
-
const [scheme, setScheme] = useState<'light' | 'dark'>(() => resolveScheme(preference));
useEffect(() => {
-
if (preference === 'light' || preference === 'dark') {
setScheme(preference);
return;
}
-
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
-
setScheme('light');
return;
}
const media = window.matchMedia(MEDIA_QUERY);
const update = (event: MediaQueryListEvent | MediaQueryList) => {
-
setScheme(event.matches ? 'dark' : 'light');
};
update(media);
-
if (typeof media.addEventListener === 'function') {
-
media.addEventListener('change', update);
-
return () => media.removeEventListener('change', update);
}
media.addListener(update);
return () => media.removeListener(update);
···
+
import { useEffect, useState } from "react";
/**
* Possible user-facing color scheme preferences.
*/
+
export type ColorSchemePreference = "light" | "dark" | "system";
+
const MEDIA_QUERY = "(prefers-color-scheme: dark)";
/**
* Resolves a persisted preference into an explicit light/dark value.
···
* @param pref - Stored preference value (`light`, `dark`, or `system`).
* @returns Explicit light/dark scheme suitable for rendering.
*/
+
function resolveScheme(pref: ColorSchemePreference): "light" | "dark" {
+
if (pref === "light" || pref === "dark") return pref;
+
if (
+
typeof window === "undefined" ||
+
typeof window.matchMedia !== "function"
+
) {
+
return "light";
}
+
return window.matchMedia(MEDIA_QUERY).matches ? "dark" : "light";
}
/**
···
* @param preference - User preference; defaults to following the OS setting.
* @returns {'light' | 'dark'} Explicit scheme that should be used for rendering.
*/
+
export function useColorScheme(
+
preference: ColorSchemePreference = "system",
+
): "light" | "dark" {
+
const [scheme, setScheme] = useState<"light" | "dark">(() =>
+
resolveScheme(preference),
+
);
useEffect(() => {
+
if (preference === "light" || preference === "dark") {
setScheme(preference);
return;
}
+
if (
+
typeof window === "undefined" ||
+
typeof window.matchMedia !== "function"
+
) {
+
setScheme("light");
return;
}
const media = window.matchMedia(MEDIA_QUERY);
const update = (event: MediaQueryListEvent | MediaQueryList) => {
+
setScheme(event.matches ? "dark" : "light");
};
update(media);
+
if (typeof media.addEventListener === "function") {
+
media.addEventListener("change", update);
+
return () => media.removeEventListener("change", update);
}
media.addListener(update);
return () => media.removeListener(update);
+32 -13
lib/hooks/useDidResolution.ts
···
-
import { useEffect, useMemo, useState } from 'react';
-
import { useAtProto } from '../providers/AtProtoProvider';
/**
* Resolves a handle to its DID, or returns the DID immediately when provided.
···
};
if (!normalizedInput) {
reset();
-
return () => { cancelled = true; };
}
-
const isDid = normalizedInput.startsWith('did:');
-
const normalizedHandle = !isDid ? normalizedInput.toLowerCase() : undefined;
const cached = isDid
? didCache.getByDid(normalizedInput)
: didCache.getByHandle(normalizedHandle);
const initialDid = cached?.did ?? (isDid ? normalizedInput : undefined);
-
const initialHandle = cached?.handle ?? (!isDid ? normalizedHandle : undefined);
setError(undefined);
setDid(initialDid);
setHandle(initialHandle);
const needsHandleResolution = !isDid && !cached?.did;
-
const needsDocResolution = isDid && (!cached?.doc || cached.handle === undefined);
if (!needsHandleResolution && !needsDocResolution) {
setLoading(false);
-
return () => { cancelled = true; };
}
setLoading(true);
···
try {
let snapshot = cached;
if (!isDid && normalizedHandle && needsHandleResolution) {
-
snapshot = await didCache.ensureHandle(resolver, normalizedHandle);
}
if (isDid) {
-
snapshot = await didCache.ensureDidDoc(resolver, normalizedInput);
}
if (!cancelled) {
-
const resolvedDid = snapshot?.did ?? (isDid ? normalizedInput : undefined);
-
const resolvedHandle = snapshot?.handle ?? (!isDid ? normalizedHandle : undefined);
setDid(resolvedDid);
setHandle(resolvedHandle);
setError(undefined);
···
}
})();
-
return () => { cancelled = true; };
}, [normalizedInput, resolver, didCache]);
return { did, handle, error, loading };
···
+
import { useEffect, useMemo, useState } from "react";
+
import { useAtProto } from "../providers/AtProtoProvider";
/**
* Resolves a handle to its DID, or returns the DID immediately when provided.
···
};
if (!normalizedInput) {
reset();
+
return () => {
+
cancelled = true;
+
};
}
+
const isDid = normalizedInput.startsWith("did:");
+
const normalizedHandle = !isDid
+
? normalizedInput.toLowerCase()
+
: undefined;
const cached = isDid
? didCache.getByDid(normalizedInput)
: didCache.getByHandle(normalizedHandle);
const initialDid = cached?.did ?? (isDid ? normalizedInput : undefined);
+
const initialHandle =
+
cached?.handle ?? (!isDid ? normalizedHandle : undefined);
setError(undefined);
setDid(initialDid);
setHandle(initialHandle);
const needsHandleResolution = !isDid && !cached?.did;
+
const needsDocResolution =
+
isDid && (!cached?.doc || cached.handle === undefined);
if (!needsHandleResolution && !needsDocResolution) {
setLoading(false);
+
return () => {
+
cancelled = true;
+
};
}
setLoading(true);
···
try {
let snapshot = cached;
if (!isDid && normalizedHandle && needsHandleResolution) {
+
snapshot = await didCache.ensureHandle(
+
resolver,
+
normalizedHandle,
+
);
}
if (isDid) {
+
snapshot = await didCache.ensureDidDoc(
+
resolver,
+
normalizedInput,
+
);
}
if (!cancelled) {
+
const resolvedDid =
+
snapshot?.did ?? (isDid ? normalizedInput : undefined);
+
const resolvedHandle =
+
snapshot?.handle ??
+
(!isDid ? normalizedHandle : undefined);
setDid(resolvedDid);
setHandle(resolvedHandle);
setError(undefined);
···
}
})();
+
return () => {
+
cancelled = true;
+
};
}, [normalizedInput, resolver, didCache]);
return { did, handle, error, loading };
+138 -73
lib/hooks/useLatestRecord.ts
···
-
import { useEffect, useState } from 'react';
-
import { useDidResolution } from './useDidResolution';
-
import { usePdsEndpoint } from './usePdsEndpoint';
-
import { createAtprotoClient } from '../utils/atproto-client';
/**
* Shape of the state returned by {@link useLatestRecord}.
*/
export interface LatestRecordState<T = unknown> {
-
/** Latest record value if one exists. */
-
record?: T;
-
/** Record key for the fetched record, when derivable. */
-
rkey?: string;
-
/** Error encountered while fetching. */
-
error?: Error;
-
/** Indicates whether a fetch is in progress. */
-
loading: boolean;
-
/** `true` when the collection has zero records. */
-
empty: boolean;
}
/**
···
* @param collection - NSID of the collection to query.
* @returns {LatestRecordState<T>} Object reporting the latest record value, derived rkey, loading status, emptiness, and any error.
*/
-
export function useLatestRecord<T = unknown>(handleOrDid: string | undefined, collection: string): LatestRecordState<T> {
-
const { did, error: didError, loading: resolvingDid } = useDidResolution(handleOrDid);
-
const { endpoint, error: endpointError, loading: resolvingEndpoint } = usePdsEndpoint(did);
-
const [state, setState] = useState<LatestRecordState<T>>({ loading: !!handleOrDid, empty: false });
-
useEffect(() => {
-
let cancelled = false;
-
const assign = (next: Partial<LatestRecordState<T>>) => {
-
if (cancelled) return;
-
setState(prev => ({ ...prev, ...next }));
-
};
-
if (!handleOrDid) {
-
assign({ loading: false, record: undefined, rkey: undefined, error: undefined, empty: false });
-
return () => { cancelled = true; };
-
}
-
if (didError) {
-
assign({ loading: false, error: didError, empty: false });
-
return () => { cancelled = true; };
-
}
-
if (endpointError) {
-
assign({ loading: false, error: endpointError, empty: false });
-
return () => { cancelled = true; };
-
}
-
if (resolvingDid || resolvingEndpoint || !did || !endpoint) {
-
assign({ loading: true, error: undefined });
-
return () => { cancelled = true; };
-
}
-
assign({ loading: true, error: undefined, empty: false });
-
(async () => {
-
try {
-
const { rpc } = await createAtprotoClient({ service: endpoint });
-
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', {
-
params: { repo: did, collection, limit: 1, reverse: false }
-
});
-
if (!res.ok) throw new Error('Failed to list records');
-
const list = res.data.records;
-
if (list.length === 0) {
-
assign({ loading: false, empty: true, record: undefined, rkey: undefined });
-
return;
-
}
-
const first = list[0];
-
const derivedRkey = first.rkey ?? extractRkey(first.uri);
-
assign({ record: first.value, rkey: derivedRkey, loading: false, empty: false });
-
} catch (e) {
-
assign({ error: e as Error, loading: false, empty: false });
-
}
-
})();
-
return () => {
-
cancelled = true;
-
};
-
}, [handleOrDid, did, endpoint, collection, resolvingDid, resolvingEndpoint, didError, endpointError]);
-
return state;
}
function extractRkey(uri: string): string | undefined {
-
if (!uri) return undefined;
-
const parts = uri.split('/');
-
return parts[parts.length - 1];
}
···
+
import { useEffect, useState } from "react";
+
import { useDidResolution } from "./useDidResolution";
+
import { usePdsEndpoint } from "./usePdsEndpoint";
+
import { createAtprotoClient } from "../utils/atproto-client";
/**
* Shape of the state returned by {@link useLatestRecord}.
*/
export interface LatestRecordState<T = unknown> {
+
/** Latest record value if one exists. */
+
record?: T;
+
/** Record key for the fetched record, when derivable. */
+
rkey?: string;
+
/** Error encountered while fetching. */
+
error?: Error;
+
/** Indicates whether a fetch is in progress. */
+
loading: boolean;
+
/** `true` when the collection has zero records. */
+
empty: boolean;
}
/**
···
* @param collection - NSID of the collection to query.
* @returns {LatestRecordState<T>} Object reporting the latest record value, derived rkey, loading status, emptiness, and any error.
*/
+
export function useLatestRecord<T = unknown>(
+
handleOrDid: string | undefined,
+
collection: string,
+
): LatestRecordState<T> {
+
const {
+
did,
+
error: didError,
+
loading: resolvingDid,
+
} = useDidResolution(handleOrDid);
+
const {
+
endpoint,
+
error: endpointError,
+
loading: resolvingEndpoint,
+
} = usePdsEndpoint(did);
+
const [state, setState] = useState<LatestRecordState<T>>({
+
loading: !!handleOrDid,
+
empty: false,
+
});
+
useEffect(() => {
+
let cancelled = false;
+
const assign = (next: Partial<LatestRecordState<T>>) => {
+
if (cancelled) return;
+
setState((prev) => ({ ...prev, ...next }));
+
};
+
if (!handleOrDid) {
+
assign({
+
loading: false,
+
record: undefined,
+
rkey: undefined,
+
error: undefined,
+
empty: false,
+
});
+
return () => {
+
cancelled = true;
+
};
+
}
+
if (didError) {
+
assign({ loading: false, error: didError, empty: false });
+
return () => {
+
cancelled = true;
+
};
+
}
+
if (endpointError) {
+
assign({ loading: false, error: endpointError, empty: false });
+
return () => {
+
cancelled = true;
+
};
+
}
+
if (resolvingDid || resolvingEndpoint || !did || !endpoint) {
+
assign({ loading: true, error: undefined });
+
return () => {
+
cancelled = true;
+
};
+
}
+
assign({ loading: true, error: undefined, empty: false });
+
(async () => {
+
try {
+
const { rpc } = await createAtprotoClient({
+
service: endpoint,
+
});
+
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", {
+
params: { repo: did, collection, limit: 1, reverse: false },
+
});
+
if (!res.ok) throw new Error("Failed to list records");
+
const list = res.data.records;
+
if (list.length === 0) {
+
assign({
+
loading: false,
+
empty: true,
+
record: undefined,
+
rkey: undefined,
+
});
+
return;
+
}
+
const first = list[0];
+
const derivedRkey = first.rkey ?? extractRkey(first.uri);
+
assign({
+
record: first.value,
+
rkey: derivedRkey,
+
loading: false,
+
empty: false,
+
});
+
} catch (e) {
+
assign({ error: e as Error, loading: false, empty: false });
+
}
+
})();
+
return () => {
+
cancelled = true;
+
};
+
}, [
+
handleOrDid,
+
did,
+
endpoint,
+
collection,
+
resolvingDid,
+
resolvingEndpoint,
+
didError,
+
endpointError,
+
]);
+
return state;
}
function extractRkey(uri: string): string | undefined {
+
if (!uri) return undefined;
+
const parts = uri.split("/");
+
return parts[parts.length - 1];
}
+412 -318
lib/hooks/usePaginatedRecords.ts
···
-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-
import { useDidResolution } from './useDidResolution';
-
import { usePdsEndpoint } from './usePdsEndpoint';
-
import { createAtprotoClient } from '../utils/atproto-client';
/**
* Record envelope returned by paginated AT Protocol queries.
*/
export interface PaginatedRecord<T> {
-
/** Fully qualified AT URI for the record. */
-
uri: string;
-
/** Record key extracted from the URI or provided by the API. */
-
rkey: string;
-
/** Raw record value. */
-
value: T;
-
/** Optional feed metadata (for example, repost context). */
-
reason?: AuthorFeedReason;
-
/** Optional reply context derived from feed metadata. */
-
replyParent?: ReplyParentInfo;
}
interface PageData<T> {
-
records: PaginatedRecord<T>[];
-
cursor?: string;
}
/**
* Options accepted by {@link usePaginatedRecords}.
*/
export interface UsePaginatedRecordsOptions {
-
/** DID or handle whose repository should be queried. */
-
did?: string;
-
/** NSID collection containing the target records. */
-
collection: string;
-
/** Maximum page size to request; defaults to `5`. */
-
limit?: number;
-
/** Prefer the Bluesky appview author feed endpoint before falling back to the PDS. */
-
preferAuthorFeed?: boolean;
-
/** Optional filter applied when fetching from the appview author feed. */
-
authorFeedFilter?: AuthorFeedFilter;
-
/** Whether to include pinned posts when fetching from the author feed. */
-
authorFeedIncludePins?: boolean;
-
/** Override for the appview service base URL used to query the author feed. */
-
authorFeedService?: string;
-
/** Optional explicit actor identifier for the author feed request. */
-
authorFeedActor?: string;
}
/**
* Result returned from {@link usePaginatedRecords} describing records and pagination state.
*/
export interface UsePaginatedRecordsResult<T> {
-
/** Records for the active page. */
-
records: PaginatedRecord<T>[];
-
/** Indicates whether a page load is in progress. */
-
loading: boolean;
-
/** Error produced during the latest fetch, if any. */
-
error?: Error;
-
/** `true` when another page can be fetched forward. */
-
hasNext: boolean;
-
/** `true` when a previous page exists in memory. */
-
hasPrev: boolean;
-
/** Requests the next page (if available). */
-
loadNext: () => void;
-
/** Returns to the previous page when possible. */
-
loadPrev: () => void;
-
/** Index of the currently displayed page. */
-
pageIndex: number;
-
/** Number of pages fetched so far (or inferred total when known). */
-
pagesCount: number;
}
-
const DEFAULT_APPVIEW_SERVICE = 'https://public.api.bsky.app';
export type AuthorFeedFilter =
-
| 'posts_with_replies'
-
| 'posts_no_replies'
-
| 'posts_with_media'
-
| 'posts_and_author_threads'
-
| 'posts_with_video';
export interface AuthorFeedReason {
-
$type?: string;
-
by?: {
-
handle?: string;
-
did?: string;
-
};
-
indexedAt?: string;
}
export interface ReplyParentInfo {
-
uri?: string;
-
author?: {
-
handle?: string;
-
did?: string;
-
};
}
/**
···
* @returns {UsePaginatedRecordsResult<T>} Object containing the current page, pagination metadata, and navigation callbacks.
*/
export function usePaginatedRecords<T>({
-
did: handleOrDid,
-
collection,
-
limit = 5,
-
preferAuthorFeed = false,
-
authorFeedFilter,
-
authorFeedIncludePins,
-
authorFeedService,
-
authorFeedActor
}: UsePaginatedRecordsOptions): UsePaginatedRecordsResult<T> {
-
const { did, handle, error: didError, loading: resolvingDid } = useDidResolution(handleOrDid);
-
const { endpoint, error: endpointError, loading: resolvingEndpoint } = usePdsEndpoint(did);
-
const [pages, setPages] = useState<PageData<T>[]>([]);
-
const [pageIndex, setPageIndex] = useState(0);
-
const [loading, setLoading] = useState(false);
-
const [error, setError] = useState<Error | undefined>(undefined);
-
const inFlight = useRef<Set<string>>(new Set());
-
const requestSeq = useRef(0);
-
const identityRef = useRef<string | undefined>(undefined);
-
const feedDisabledRef = useRef(false);
-
const identity = did && endpoint ? `${did}::${endpoint}` : undefined;
-
const normalizedInput = useMemo(() => {
-
if (!handleOrDid) return undefined;
-
const trimmed = handleOrDid.trim();
-
return trimmed || undefined;
-
}, [handleOrDid]);
-
const actorIdentifier = useMemo(() => {
-
const explicit = authorFeedActor?.trim();
-
if (explicit) return explicit;
-
if (handle) return handle;
-
if (normalizedInput) return normalizedInput;
-
if (did) return did;
-
return undefined;
-
}, [authorFeedActor, handle, normalizedInput, did]);
-
const resetState = useCallback(() => {
-
setPages([]);
-
setPageIndex(0);
-
setError(undefined);
-
inFlight.current.clear();
-
requestSeq.current += 1;
-
feedDisabledRef.current = false;
-
}, []);
-
const fetchPage = useCallback(async (identityKey: string, cursor: string | undefined, targetIndex: number, mode: 'active' | 'prefetch') => {
-
if (!did || !endpoint) return;
-
const currentIdentity = `${did}::${endpoint}`;
-
if (identityKey !== currentIdentity) return;
-
const token = requestSeq.current;
-
const key = `${identityKey}:${targetIndex}:${cursor ?? 'start'}`;
-
if (inFlight.current.has(key)) return;
-
inFlight.current.add(key);
-
if (mode === 'active') {
-
setLoading(true);
-
setError(undefined);
-
}
-
try {
-
let nextCursor: string | undefined;
-
let mapped: PaginatedRecord<T>[] | undefined;
-
const shouldUseAuthorFeed = preferAuthorFeed && collection === 'app.bsky.feed.post' && !feedDisabledRef.current && !!actorIdentifier;
-
if (shouldUseAuthorFeed) {
-
try {
-
const { rpc } = await createAtprotoClient({ service: authorFeedService ?? DEFAULT_APPVIEW_SERVICE });
-
const res = await (rpc as unknown as {
-
get: (
-
nsid: string,
-
opts: { params: Record<string, string | number | boolean | undefined> }
-
) => Promise<{
-
ok: boolean;
-
data: {
-
feed?: Array<{
-
post?: {
-
uri?: string;
-
record?: T;
-
reply?: {
-
parent?: {
-
uri?: string;
-
author?: { handle?: string; did?: string };
-
};
-
};
-
};
-
reason?: AuthorFeedReason;
-
}>;
-
cursor?: string;
-
};
-
}>;
-
}).get('app.bsky.feed.getAuthorFeed', {
-
params: {
-
actor: actorIdentifier,
-
limit,
-
cursor,
-
filter: authorFeedFilter,
-
includePins: authorFeedIncludePins
-
}
-
});
-
if (!res.ok) throw new Error('Failed to fetch author feed');
-
const { feed, cursor: feedCursor } = res.data;
-
mapped = (feed ?? []).reduce<PaginatedRecord<T>[]>((acc, item) => {
-
const post = item?.post;
-
if (!post || typeof post.uri !== 'string' || !post.record) return acc;
-
acc.push({
-
uri: post.uri,
-
rkey: extractRkey(post.uri),
-
value: post.record as T,
-
reason: item?.reason,
-
replyParent: post.reply?.parent
-
});
-
return acc;
-
}, []);
-
nextCursor = feedCursor;
-
} catch (err) {
-
feedDisabledRef.current = true;
-
}
-
}
-
if (!mapped) {
-
const { rpc } = await createAtprotoClient({ service: endpoint });
-
const res = await (rpc as unknown as {
-
get: (
-
nsid: string,
-
opts: { params: Record<string, string | number | boolean | undefined> }
-
) => Promise<{ ok: boolean; data: { records: Array<{ uri: string; rkey?: string; value: T }>; cursor?: string } }>;
-
}).get('com.atproto.repo.listRecords', {
-
params: {
-
repo: did,
-
collection,
-
limit,
-
cursor,
-
reverse: false
-
}
-
});
-
if (!res.ok) throw new Error('Failed to list records');
-
const { records, cursor: repoCursor } = res.data;
-
mapped = records.map((item) => ({
-
uri: item.uri,
-
rkey: item.rkey ?? extractRkey(item.uri),
-
value: item.value
-
}));
-
nextCursor = repoCursor;
-
}
-
if (token !== requestSeq.current || identityKey !== identityRef.current) {
-
return nextCursor;
-
}
-
if (mode === 'active') setPageIndex(targetIndex);
-
setPages(prev => {
-
const next = [...prev];
-
next[targetIndex] = { records: mapped!, cursor: nextCursor };
-
return next;
-
});
-
return nextCursor;
-
} catch (e) {
-
if (mode === 'active' && token === requestSeq.current && identityKey === identityRef.current) {
-
setError(e as Error);
-
}
-
} finally {
-
if (mode === 'active' && token === requestSeq.current && identityKey === identityRef.current) {
-
setLoading(false);
-
}
-
inFlight.current.delete(key);
-
}
-
return undefined;
-
}, [
-
did,
-
endpoint,
-
collection,
-
limit,
-
preferAuthorFeed,
-
actorIdentifier,
-
authorFeedService,
-
authorFeedFilter,
-
authorFeedIncludePins
-
]);
-
useEffect(() => {
-
if (!handleOrDid) {
-
identityRef.current = undefined;
-
resetState();
-
setLoading(false);
-
setError(undefined);
-
return;
-
}
-
if (didError) {
-
identityRef.current = undefined;
-
resetState();
-
setLoading(false);
-
setError(didError);
-
return;
-
}
-
if (endpointError) {
-
identityRef.current = undefined;
-
resetState();
-
setLoading(false);
-
setError(endpointError);
-
return;
-
}
-
if (resolvingDid || resolvingEndpoint || !identity) {
-
if (identityRef.current !== identity) {
-
identityRef.current = identity;
-
resetState();
-
}
-
setLoading(!!handleOrDid);
-
setError(undefined);
-
return;
-
}
-
if (identityRef.current !== identity) {
-
identityRef.current = identity;
-
resetState();
-
}
-
fetchPage(identity, undefined, 0, 'active').catch(() => {
-
/* error handled in state */
-
});
-
}, [handleOrDid, identity, fetchPage, resetState, resolvingDid, resolvingEndpoint, didError, endpointError]);
-
const currentPage = pages[pageIndex];
-
const hasNext = !!currentPage?.cursor || !!pages[pageIndex + 1];
-
const hasPrev = pageIndex > 0;
-
const loadNext = useCallback(() => {
-
const identityKey = identityRef.current;
-
if (!identityKey) return;
-
const page = pages[pageIndex];
-
if (!page?.cursor && !pages[pageIndex + 1]) return;
-
if (pages[pageIndex + 1]) {
-
setPageIndex(pageIndex + 1);
-
return;
-
}
-
fetchPage(identityKey, page.cursor, pageIndex + 1, 'active').catch(() => {
-
/* handled via error state */
-
});
-
}, [fetchPage, pageIndex, pages]);
-
const loadPrev = useCallback(() => {
-
if (pageIndex === 0) return;
-
setPageIndex(pageIndex - 1);
-
}, [pageIndex]);
-
const records = useMemo(() => currentPage?.records ?? [], [currentPage]);
-
const effectiveError = error ?? (endpointError as Error | undefined) ?? (didError as Error | undefined);
-
useEffect(() => {
-
const cursor = pages[pageIndex]?.cursor;
-
if (!cursor) return;
-
if (pages[pageIndex + 1]) return;
-
const identityKey = identityRef.current;
-
if (!identityKey) return;
-
fetchPage(identityKey, cursor, pageIndex + 1, 'prefetch').catch(() => {
-
/* ignore prefetch errors */
-
});
-
}, [fetchPage, pageIndex, pages]);
-
return {
-
records,
-
loading,
-
error: effectiveError,
-
hasNext,
-
hasPrev,
-
loadNext,
-
loadPrev,
-
pageIndex,
-
pagesCount: pages.length || (currentPage ? pageIndex + 1 : 0)
-
};
}
function extractRkey(uri: string): string {
-
const parts = uri.split('/');
-
return parts[parts.length - 1];
}
···
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+
import { useDidResolution } from "./useDidResolution";
+
import { usePdsEndpoint } from "./usePdsEndpoint";
+
import { createAtprotoClient } from "../utils/atproto-client";
/**
* Record envelope returned by paginated AT Protocol queries.
*/
export interface PaginatedRecord<T> {
+
/** Fully qualified AT URI for the record. */
+
uri: string;
+
/** Record key extracted from the URI or provided by the API. */
+
rkey: string;
+
/** Raw record value. */
+
value: T;
+
/** Optional feed metadata (for example, repost context). */
+
reason?: AuthorFeedReason;
+
/** Optional reply context derived from feed metadata. */
+
replyParent?: ReplyParentInfo;
}
interface PageData<T> {
+
records: PaginatedRecord<T>[];
+
cursor?: string;
}
/**
* Options accepted by {@link usePaginatedRecords}.
*/
export interface UsePaginatedRecordsOptions {
+
/** DID or handle whose repository should be queried. */
+
did?: string;
+
/** NSID collection containing the target records. */
+
collection: string;
+
/** Maximum page size to request; defaults to `5`. */
+
limit?: number;
+
/** Prefer the Bluesky appview author feed endpoint before falling back to the PDS. */
+
preferAuthorFeed?: boolean;
+
/** Optional filter applied when fetching from the appview author feed. */
+
authorFeedFilter?: AuthorFeedFilter;
+
/** Whether to include pinned posts when fetching from the author feed. */
+
authorFeedIncludePins?: boolean;
+
/** Override for the appview service base URL used to query the author feed. */
+
authorFeedService?: string;
+
/** Optional explicit actor identifier for the author feed request. */
+
authorFeedActor?: string;
}
/**
* Result returned from {@link usePaginatedRecords} describing records and pagination state.
*/
export interface UsePaginatedRecordsResult<T> {
+
/** Records for the active page. */
+
records: PaginatedRecord<T>[];
+
/** Indicates whether a page load is in progress. */
+
loading: boolean;
+
/** Error produced during the latest fetch, if any. */
+
error?: Error;
+
/** `true` when another page can be fetched forward. */
+
hasNext: boolean;
+
/** `true` when a previous page exists in memory. */
+
hasPrev: boolean;
+
/** Requests the next page (if available). */
+
loadNext: () => void;
+
/** Returns to the previous page when possible. */
+
loadPrev: () => void;
+
/** Index of the currently displayed page. */
+
pageIndex: number;
+
/** Number of pages fetched so far (or inferred total when known). */
+
pagesCount: number;
}
+
const DEFAULT_APPVIEW_SERVICE = "https://public.api.bsky.app";
export type AuthorFeedFilter =
+
| "posts_with_replies"
+
| "posts_no_replies"
+
| "posts_with_media"
+
| "posts_and_author_threads"
+
| "posts_with_video";
export interface AuthorFeedReason {
+
$type?: string;
+
by?: {
+
handle?: string;
+
did?: string;
+
};
+
indexedAt?: string;
}
export interface ReplyParentInfo {
+
uri?: string;
+
author?: {
+
handle?: string;
+
did?: string;
+
};
}
/**
···
* @returns {UsePaginatedRecordsResult<T>} Object containing the current page, pagination metadata, and navigation callbacks.
*/
export function usePaginatedRecords<T>({
+
did: handleOrDid,
+
collection,
+
limit = 5,
+
preferAuthorFeed = false,
+
authorFeedFilter,
+
authorFeedIncludePins,
+
authorFeedService,
+
authorFeedActor,
}: UsePaginatedRecordsOptions): UsePaginatedRecordsResult<T> {
+
const {
+
did,
+
handle,
+
error: didError,
+
loading: resolvingDid,
+
} = useDidResolution(handleOrDid);
+
const {
+
endpoint,
+
error: endpointError,
+
loading: resolvingEndpoint,
+
} = usePdsEndpoint(did);
+
const [pages, setPages] = useState<PageData<T>[]>([]);
+
const [pageIndex, setPageIndex] = useState(0);
+
const [loading, setLoading] = useState(false);
+
const [error, setError] = useState<Error | undefined>(undefined);
+
const inFlight = useRef<Set<string>>(new Set());
+
const requestSeq = useRef(0);
+
const identityRef = useRef<string | undefined>(undefined);
+
const feedDisabledRef = useRef(false);
+
const identity = did && endpoint ? `${did}::${endpoint}` : undefined;
+
const normalizedInput = useMemo(() => {
+
if (!handleOrDid) return undefined;
+
const trimmed = handleOrDid.trim();
+
return trimmed || undefined;
+
}, [handleOrDid]);
+
const actorIdentifier = useMemo(() => {
+
const explicit = authorFeedActor?.trim();
+
if (explicit) return explicit;
+
if (handle) return handle;
+
if (normalizedInput) return normalizedInput;
+
if (did) return did;
+
return undefined;
+
}, [authorFeedActor, handle, normalizedInput, did]);
+
const resetState = useCallback(() => {
+
setPages([]);
+
setPageIndex(0);
+
setError(undefined);
+
inFlight.current.clear();
+
requestSeq.current += 1;
+
feedDisabledRef.current = false;
+
}, []);
+
const fetchPage = useCallback(
+
async (
+
identityKey: string,
+
cursor: string | undefined,
+
targetIndex: number,
+
mode: "active" | "prefetch",
+
) => {
+
if (!did || !endpoint) return;
+
const currentIdentity = `${did}::${endpoint}`;
+
if (identityKey !== currentIdentity) return;
+
const token = requestSeq.current;
+
const key = `${identityKey}:${targetIndex}:${cursor ?? "start"}`;
+
if (inFlight.current.has(key)) return;
+
inFlight.current.add(key);
+
if (mode === "active") {
+
setLoading(true);
+
setError(undefined);
+
}
+
try {
+
let nextCursor: string | undefined;
+
let mapped: PaginatedRecord<T>[] | undefined;
+
const shouldUseAuthorFeed =
+
preferAuthorFeed &&
+
collection === "app.bsky.feed.post" &&
+
!feedDisabledRef.current &&
+
!!actorIdentifier;
+
if (shouldUseAuthorFeed) {
+
try {
+
const { rpc } = await createAtprotoClient({
+
service:
+
authorFeedService ?? DEFAULT_APPVIEW_SERVICE,
+
});
+
const res = await (
+
rpc as unknown as {
+
get: (
+
nsid: string,
+
opts: {
+
params: Record<
+
string,
+
| string
+
| number
+
| boolean
+
| undefined
+
>;
+
},
+
) => Promise<{
+
ok: boolean;
+
data: {
+
feed?: Array<{
+
post?: {
+
uri?: string;
+
record?: T;
+
reply?: {
+
parent?: {
+
uri?: string;
+
author?: {
+
handle?: string;
+
did?: string;
+
};
+
};
+
};
+
};
+
reason?: AuthorFeedReason;
+
}>;
+
cursor?: string;
+
};
+
}>;
+
}
+
).get("app.bsky.feed.getAuthorFeed", {
+
params: {
+
actor: actorIdentifier,
+
limit,
+
cursor,
+
filter: authorFeedFilter,
+
includePins: authorFeedIncludePins,
+
},
+
});
+
if (!res.ok)
+
throw new Error("Failed to fetch author feed");
+
const { feed, cursor: feedCursor } = res.data;
+
mapped = (feed ?? []).reduce<PaginatedRecord<T>[]>(
+
(acc, item) => {
+
const post = item?.post;
+
if (
+
!post ||
+
typeof post.uri !== "string" ||
+
!post.record
+
)
+
return acc;
+
acc.push({
+
uri: post.uri,
+
rkey: extractRkey(post.uri),
+
value: post.record as T,
+
reason: item?.reason,
+
replyParent: post.reply?.parent,
+
});
+
return acc;
+
},
+
[],
+
);
+
nextCursor = feedCursor;
+
} catch (err) {
+
console.log(err);
+
feedDisabledRef.current = true;
+
}
+
}
+
if (!mapped) {
+
const { rpc } = await createAtprotoClient({
+
service: endpoint,
+
});
+
const res = await (
+
rpc as unknown as {
+
get: (
+
nsid: string,
+
opts: {
+
params: Record<
+
string,
+
string | number | boolean | undefined
+
>;
+
},
+
) => Promise<{
+
ok: boolean;
+
data: {
+
records: Array<{
+
uri: string;
+
rkey?: string;
+
value: T;
+
}>;
+
cursor?: string;
+
};
+
}>;
+
}
+
).get("com.atproto.repo.listRecords", {
+
params: {
+
repo: did,
+
collection,
+
limit,
+
cursor,
+
reverse: false,
+
},
+
});
+
if (!res.ok) throw new Error("Failed to list records");
+
const { records, cursor: repoCursor } = res.data;
+
mapped = records.map((item) => ({
+
uri: item.uri,
+
rkey: item.rkey ?? extractRkey(item.uri),
+
value: item.value,
+
}));
+
nextCursor = repoCursor;
+
}
+
if (
+
token !== requestSeq.current ||
+
identityKey !== identityRef.current
+
) {
+
return nextCursor;
+
}
+
if (mode === "active") setPageIndex(targetIndex);
+
setPages((prev) => {
+
const next = [...prev];
+
next[targetIndex] = {
+
records: mapped!,
+
cursor: nextCursor,
+
};
+
return next;
+
});
+
return nextCursor;
+
} catch (e) {
+
if (
+
mode === "active" &&
+
token === requestSeq.current &&
+
identityKey === identityRef.current
+
) {
+
setError(e as Error);
+
}
+
} finally {
+
if (
+
mode === "active" &&
+
token === requestSeq.current &&
+
identityKey === identityRef.current
+
) {
+
setLoading(false);
+
}
+
inFlight.current.delete(key);
+
}
+
return undefined;
+
},
+
[
+
did,
+
endpoint,
+
collection,
+
limit,
+
preferAuthorFeed,
+
actorIdentifier,
+
authorFeedService,
+
authorFeedFilter,
+
authorFeedIncludePins,
+
],
+
);
+
useEffect(() => {
+
if (!handleOrDid) {
+
identityRef.current = undefined;
+
resetState();
+
setLoading(false);
+
setError(undefined);
+
return;
+
}
+
if (didError) {
+
identityRef.current = undefined;
+
resetState();
+
setLoading(false);
+
setError(didError);
+
return;
+
}
+
if (endpointError) {
+
identityRef.current = undefined;
+
resetState();
+
setLoading(false);
+
setError(endpointError);
+
return;
+
}
+
if (resolvingDid || resolvingEndpoint || !identity) {
+
if (identityRef.current !== identity) {
+
identityRef.current = identity;
+
resetState();
+
}
+
setLoading(!!handleOrDid);
+
setError(undefined);
+
return;
+
}
+
if (identityRef.current !== identity) {
+
identityRef.current = identity;
+
resetState();
+
}
+
fetchPage(identity, undefined, 0, "active").catch(() => {
+
/* error handled in state */
+
});
+
}, [
+
handleOrDid,
+
identity,
+
fetchPage,
+
resetState,
+
resolvingDid,
+
resolvingEndpoint,
+
didError,
+
endpointError,
+
]);
+
const currentPage = pages[pageIndex];
+
const hasNext = !!currentPage?.cursor || !!pages[pageIndex + 1];
+
const hasPrev = pageIndex > 0;
+
const loadNext = useCallback(() => {
+
const identityKey = identityRef.current;
+
if (!identityKey) return;
+
const page = pages[pageIndex];
+
if (!page?.cursor && !pages[pageIndex + 1]) return;
+
if (pages[pageIndex + 1]) {
+
setPageIndex(pageIndex + 1);
+
return;
+
}
+
fetchPage(identityKey, page.cursor, pageIndex + 1, "active").catch(
+
() => {
+
/* handled via error state */
+
},
+
);
+
}, [fetchPage, pageIndex, pages]);
+
const loadPrev = useCallback(() => {
+
if (pageIndex === 0) return;
+
setPageIndex(pageIndex - 1);
+
}, [pageIndex]);
+
const records = useMemo(() => currentPage?.records ?? [], [currentPage]);
+
const effectiveError =
+
error ??
+
(endpointError as Error | undefined) ??
+
(didError as Error | undefined);
+
useEffect(() => {
+
const cursor = pages[pageIndex]?.cursor;
+
if (!cursor) return;
+
if (pages[pageIndex + 1]) return;
+
const identityKey = identityRef.current;
+
if (!identityKey) return;
+
fetchPage(identityKey, cursor, pageIndex + 1, "prefetch").catch(() => {
+
/* ignore prefetch errors */
+
});
+
}, [fetchPage, pageIndex, pages]);
+
return {
+
records,
+
loading,
+
error: effectiveError,
+
hasNext,
+
hasPrev,
+
loadNext,
+
loadPrev,
+
pageIndex,
+
pagesCount: pages.length || (currentPage ? pageIndex + 1 : 0),
+
};
}
function extractRkey(uri: string): string {
+
const parts = uri.split("/");
+
return parts[parts.length - 1];
}
+15 -8
lib/hooks/usePdsEndpoint.ts
···
-
import { useEffect, useState } from 'react';
-
import { useAtProto } from '../providers/AtProtoProvider';
/**
* Resolves the PDS service endpoint for a given DID and tracks loading state.
···
setEndpoint(undefined);
setError(undefined);
setLoading(false);
-
return () => { cancelled = true; };
}
const cached = didCache.getByDid(did);
···
setEndpoint(cached.pdsEndpoint);
setError(undefined);
setLoading(false);
-
return () => { cancelled = true; };
}
setEndpoint(undefined);
setLoading(true);
setError(undefined);
-
didCache.ensurePdsEndpoint(resolver, did)
-
.then(snapshot => {
if (cancelled) return;
setEndpoint(snapshot.pdsEndpoint);
})
-
.catch(e => {
if (cancelled) return;
setError(e as Error);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
-
return () => { cancelled = true; };
}, [did, resolver, didCache]);
return { endpoint, error, loading };
···
+
import { useEffect, useState } from "react";
+
import { useAtProto } from "../providers/AtProtoProvider";
/**
* Resolves the PDS service endpoint for a given DID and tracks loading state.
···
setEndpoint(undefined);
setError(undefined);
setLoading(false);
+
return () => {
+
cancelled = true;
+
};
}
const cached = didCache.getByDid(did);
···
setEndpoint(cached.pdsEndpoint);
setError(undefined);
setLoading(false);
+
return () => {
+
cancelled = true;
+
};
}
setEndpoint(undefined);
setLoading(true);
setError(undefined);
+
didCache
+
.ensurePdsEndpoint(resolver, did)
+
.then((snapshot) => {
if (cancelled) return;
setEndpoint(snapshot.pdsEndpoint);
})
+
.catch((e) => {
if (cancelled) return;
setError(e as Error);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
+
return () => {
+
cancelled = true;
+
};
}, [did, resolver, didCache]);
return { endpoint, error, loading };
+27 -27
lib/index.ts
···
// Master exporter for the AT React component library.
// Providers & core primitives
-
export * from './providers/AtProtoProvider';
-
export * from './core/AtProtoRecord';
// Components
-
export * from './components/BlueskyIcon';
-
export * from './components/BlueskyPost';
-
export * from './components/BlueskyPostList';
-
export * from './components/BlueskyProfile';
-
export * from './components/BlueskyQuotePost';
-
export * from './components/ColorSchemeToggle';
-
export * from './components/LeafletDocument';
-
export * from './components/TangledString';
// Hooks
-
export * from './hooks/useAtProtoRecord';
-
export * from './hooks/useBlob';
-
export * from './hooks/useBlueskyProfile';
-
export * from './hooks/useColorScheme';
-
export * from './hooks/useDidResolution';
-
export * from './hooks/useLatestRecord';
-
export * from './hooks/usePaginatedRecords';
-
export * from './hooks/usePdsEndpoint';
// Renderers
-
export * from './renderers/BlueskyPostRenderer';
-
export * from './renderers/BlueskyProfileRenderer';
-
export * from './renderers/LeafletDocumentRenderer';
-
export * from './renderers/TangledStringRenderer';
// Types
-
export * from './types/bluesky';
-
export * from './types/leaflet';
// Utilities
-
export * from './utils/at-uri';
-
export * from './utils/atproto-client';
-
export * from './utils/profile';
···
// Master exporter for the AT React component library.
// Providers & core primitives
+
export * from "./providers/AtProtoProvider";
+
export * from "./core/AtProtoRecord";
// Components
+
export * from "./components/BlueskyIcon";
+
export * from "./components/BlueskyPost";
+
export * from "./components/BlueskyPostList";
+
export * from "./components/BlueskyProfile";
+
export * from "./components/BlueskyQuotePost";
+
export * from "./components/ColorSchemeToggle";
+
export * from "./components/LeafletDocument";
+
export * from "./components/TangledString";
// Hooks
+
export * from "./hooks/useAtProtoRecord";
+
export * from "./hooks/useBlob";
+
export * from "./hooks/useBlueskyProfile";
+
export * from "./hooks/useColorScheme";
+
export * from "./hooks/useDidResolution";
+
export * from "./hooks/useLatestRecord";
+
export * from "./hooks/usePaginatedRecords";
+
export * from "./hooks/usePdsEndpoint";
// Renderers
+
export * from "./renderers/BlueskyPostRenderer";
+
export * from "./renderers/BlueskyProfileRenderer";
+
export * from "./renderers/LeafletDocumentRenderer";
+
export * from "./renderers/TangledStringRenderer";
// Types
+
export * from "./types/bluesky";
+
export * from "./types/leaflet";
// Utilities
+
export * from "./utils/at-uri";
+
export * from "./utils/atproto-client";
+
export * from "./utils/profile";
+46 -17
lib/providers/AtProtoProvider.tsx
···
/* eslint-disable react-refresh/only-export-components */
-
import React, { createContext, useContext, useMemo, useRef } from 'react';
-
import { ServiceResolver, normalizeBaseUrl } from '../utils/atproto-client';
-
import { BlobCache, DidCache } from '../utils/cache';
export interface AtProtoProviderProps {
children: React.ReactNode;
···
blobCache: BlobCache;
}
-
const AtProtoContext = createContext<AtProtoContextValue | undefined>(undefined);
-
export function AtProtoProvider({ children, plcDirectory }: AtProtoProviderProps) {
-
const normalizedPlc = useMemo(() => normalizeBaseUrl(plcDirectory && plcDirectory.trim() ? plcDirectory : 'https://plc.directory'), [plcDirectory]);
-
const resolver = useMemo(() => new ServiceResolver({ plcDirectory: normalizedPlc }), [normalizedPlc]);
-
const cachesRef = useRef<{ didCache: DidCache; blobCache: BlobCache } | null>(null);
if (!cachesRef.current) {
-
cachesRef.current = { didCache: new DidCache(), blobCache: new BlobCache() };
}
-
const value = useMemo<AtProtoContextValue>(() => ({
-
resolver,
-
plcDirectory: normalizedPlc,
-
didCache: cachesRef.current!.didCache,
-
blobCache: cachesRef.current!.blobCache,
-
}), [resolver, normalizedPlc]);
-
return <AtProtoContext.Provider value={value}>{children}</AtProtoContext.Provider>;
}
export function useAtProto() {
const ctx = useContext(AtProtoContext);
-
if (!ctx) throw new Error('useAtProto must be used within AtProtoProvider');
return ctx;
}
···
/* eslint-disable react-refresh/only-export-components */
+
import React, { createContext, useContext, useMemo, useRef } from "react";
+
import { ServiceResolver, normalizeBaseUrl } from "../utils/atproto-client";
+
import { BlobCache, DidCache } from "../utils/cache";
export interface AtProtoProviderProps {
children: React.ReactNode;
···
blobCache: BlobCache;
}
+
const AtProtoContext = createContext<AtProtoContextValue | undefined>(
+
undefined,
+
);
+
export function AtProtoProvider({
+
children,
+
plcDirectory,
+
}: AtProtoProviderProps) {
+
const normalizedPlc = useMemo(
+
() =>
+
normalizeBaseUrl(
+
plcDirectory && plcDirectory.trim()
+
? plcDirectory
+
: "https://plc.directory",
+
),
+
[plcDirectory],
+
);
+
const resolver = useMemo(
+
() => new ServiceResolver({ plcDirectory: normalizedPlc }),
+
[normalizedPlc],
+
);
+
const cachesRef = useRef<{
+
didCache: DidCache;
+
blobCache: BlobCache;
+
} | null>(null);
if (!cachesRef.current) {
+
cachesRef.current = {
+
didCache: new DidCache(),
+
blobCache: new BlobCache(),
+
};
}
+
const value = useMemo<AtProtoContextValue>(
+
() => ({
+
resolver,
+
plcDirectory: normalizedPlc,
+
didCache: cachesRef.current!.didCache,
+
blobCache: cachesRef.current!.blobCache,
+
}),
+
[resolver, normalizedPlc],
+
);
+
return (
+
<AtProtoContext.Provider value={value}>
+
{children}
+
</AtProtoContext.Provider>
+
);
}
export function useAtProto() {
const ctx = useContext(AtProtoContext);
+
if (!ctx) throw new Error("useAtProto must be used within AtProtoProvider");
return ctx;
}
+564 -428
lib/renderers/BlueskyPostRenderer.tsx
···
-
import React from 'react';
-
import type { FeedPostRecord } from '../types/bluesky';
-
import { useColorScheme, type ColorSchemePreference } from '../hooks/useColorScheme';
-
import { parseAtUri, toBlueskyPostUrl, formatDidForLabel, type ParsedAtUri } from '../utils/at-uri';
-
import { useDidResolution } from '../hooks/useDidResolution';
-
import { useBlob } from '../hooks/useBlob';
-
import { BlueskyIcon } from '../components/BlueskyIcon';
export interface BlueskyPostRendererProps {
-
record: FeedPostRecord;
-
loading: boolean;
-
error?: Error;
-
// Optionally pass in actor display info if pre-fetched
-
authorHandle?: string;
-
authorDisplayName?: string;
-
avatarUrl?: string;
-
colorScheme?: ColorSchemePreference;
-
authorDid?: string;
-
embed?: React.ReactNode;
-
iconPlacement?: 'cardBottomRight' | 'timestamp' | 'linkInline';
-
showIcon?: boolean;
-
atUri?: string;
}
-
export const BlueskyPostRenderer: React.FC<BlueskyPostRendererProps> = ({ record, loading, error, authorDisplayName, authorHandle, avatarUrl, colorScheme = 'system', authorDid, embed, iconPlacement = 'timestamp', showIcon = true, atUri }) => {
-
const scheme = useColorScheme(colorScheme);
-
const replyParentUri = record.reply?.parent?.uri;
-
const replyTarget = replyParentUri ? parseAtUri(replyParentUri) : undefined;
-
const { handle: parentHandle, loading: parentHandleLoading } = useDidResolution(replyTarget?.did);
-
if (error) return <div style={{ padding: 8, color: 'crimson' }}>Failed to load post.</div>;
-
if (loading && !record) return <div style={{ padding: 8 }}>Loading…</div>;
-
const palette = scheme === 'dark' ? themeStyles.dark : themeStyles.light;
-
const text = record.text;
-
const createdDate = new Date(record.createdAt);
-
const created = createdDate.toLocaleString(undefined, {
-
dateStyle: 'medium',
-
timeStyle: 'short'
-
});
-
const primaryName = authorDisplayName || authorHandle || '…';
-
const replyHref = replyTarget ? toBlueskyPostUrl(replyTarget) : undefined;
-
const replyLabel = replyTarget ? formatReplyLabel(replyTarget, parentHandle, parentHandleLoading) : undefined;
-
const makeIcon = () => (showIcon ? <BlueskyIcon size={16} /> : null);
-
const resolvedEmbed = embed ?? createAutoEmbed(record, authorDid, scheme);
-
const parsedSelf = atUri ? parseAtUri(atUri) : undefined;
-
const postUrl = parsedSelf ? toBlueskyPostUrl(parsedSelf) : undefined;
-
const cardPadding = typeof baseStyles.card.padding === 'number' ? baseStyles.card.padding : 12;
-
const cardStyle: React.CSSProperties = {
-
...baseStyles.card,
-
...palette.card,
-
...(iconPlacement === 'cardBottomRight' && showIcon ? { paddingBottom: cardPadding + 16 } : {})
-
};
-
return (
-
<article style={cardStyle} aria-busy={loading}>
-
<header style={baseStyles.header}>
-
{avatarUrl ? (
-
<img src={avatarUrl} alt="avatar" style={baseStyles.avatarImg} />
-
) : (
-
<div style={{ ...baseStyles.avatarPlaceholder, ...palette.avatarPlaceholder }} aria-hidden />
-
)}
-
<div style={{ display: 'flex', flexDirection: 'column' }}>
-
<strong style={{ fontSize: 14 }}>{primaryName}</strong>
-
{authorDisplayName && authorHandle && <span style={{ ...baseStyles.handle, ...palette.handle }}>@{authorHandle}</span>}
-
</div>
-
{iconPlacement === 'timestamp' && showIcon && (
-
<div style={baseStyles.headerIcon}>{makeIcon()}</div>
-
)}
-
</header>
-
{replyHref && replyLabel && (
-
<div style={{ ...baseStyles.replyLine, ...palette.replyLine }}>
-
Replying to{' '}
-
<a href={replyHref} target="_blank" rel="noopener noreferrer" style={{ ...baseStyles.replyLink, ...palette.replyLink }}>
-
{replyLabel}
-
</a>
-
</div>
-
)}
-
<div style={baseStyles.body}>
-
<p style={{ ...baseStyles.text, ...palette.text }}>{text}</p>
-
{record.facets && record.facets.length > 0 && (
-
<div style={baseStyles.facets}>
-
{record.facets.map((_, idx) => (
-
<span key={idx} style={{ ...baseStyles.facetTag, ...palette.facetTag }}>facet</span>
-
))}
-
</div>
-
)}
-
<div style={baseStyles.timestampRow}>
-
<time style={{ ...baseStyles.time, ...palette.time }} dateTime={record.createdAt}>{created}</time>
-
{postUrl && (
-
<span style={baseStyles.linkWithIcon}>
-
<a href={postUrl} target="_blank" rel="noopener noreferrer" style={{ ...baseStyles.postLink, ...palette.postLink }}>
-
View on Bluesky
-
</a>
-
{iconPlacement === 'linkInline' && showIcon && (
-
<span style={baseStyles.inlineIcon} aria-hidden>
-
{makeIcon()}
-
</span>
-
)}
-
</span>
-
)}
-
</div>
-
{resolvedEmbed && (
-
<div style={{ ...baseStyles.embedContainer, ...palette.embedContainer }}>
-
{resolvedEmbed}
-
</div>
-
)}
-
</div>
-
{iconPlacement === 'cardBottomRight' && showIcon && (
-
<div style={baseStyles.iconCorner} aria-hidden>
-
{makeIcon()}
-
</div>
-
)}
-
</article>
-
);
};
const baseStyles: Record<string, React.CSSProperties> = {
-
card: {
-
borderRadius: 12,
-
padding: 12,
-
fontFamily: 'system-ui, sans-serif',
-
display: 'flex',
-
flexDirection: 'column',
-
gap: 8,
-
maxWidth: 600,
-
transition: 'background-color 180ms ease, border-color 180ms ease, color 180ms ease',
-
position: 'relative'
-
},
-
header: {
-
display: 'flex',
-
alignItems: 'center',
-
gap: 8
-
},
-
headerIcon: {
-
marginLeft: 'auto',
-
display: 'flex',
-
alignItems: 'center'
-
},
-
avatarPlaceholder: {
-
width: 40,
-
height: 40,
-
borderRadius: '50%'
-
},
-
avatarImg: {
-
width: 40,
-
height: 40,
-
borderRadius: '50%',
-
objectFit: 'cover'
-
},
-
handle: {
-
fontSize: 12
-
},
-
time: {
-
fontSize: 11
-
},
-
timestampIcon: {
-
display: 'flex',
-
alignItems: 'center',
-
justifyContent: 'center'
-
},
-
body: {
-
fontSize: 14,
-
lineHeight: 1.4
-
},
-
text: {
-
margin: 0,
-
whiteSpace: 'pre-wrap',
-
overflowWrap: 'anywhere'
-
},
-
facets: {
-
marginTop: 8,
-
display: 'flex',
-
gap: 4
-
},
-
embedContainer: {
-
marginTop: 12,
-
padding: 8,
-
borderRadius: 12,
-
display: 'flex',
-
flexDirection: 'column',
-
gap: 8
-
},
-
timestampRow: {
-
display: 'flex',
-
justifyContent: 'flex-end',
-
alignItems: 'center',
-
gap: 12,
-
marginTop: 12,
-
flexWrap: 'wrap'
-
},
-
linkWithIcon: {
-
display: 'inline-flex',
-
alignItems: 'center',
-
gap: 6
-
},
-
postLink: {
-
fontSize: 11,
-
textDecoration: 'none',
-
fontWeight: 600
-
},
-
inlineIcon: {
-
display: 'inline-flex',
-
alignItems: 'center'
-
},
-
facetTag: {
-
padding: '2px 6px',
-
borderRadius: 4,
-
fontSize: 11
-
},
-
replyLine: {
-
fontSize: 12
-
},
-
replyLink: {
-
textDecoration: 'none',
-
fontWeight: 500
-
},
-
iconCorner: {
-
position: 'absolute',
-
right: 12,
-
bottom: 12,
-
display: 'flex',
-
alignItems: 'center',
-
justifyContent: 'flex-end'
-
}
};
const themeStyles = {
-
light: {
-
card: {
-
border: '1px solid #e2e8f0',
-
background: '#ffffff',
-
color: '#0f172a'
-
},
-
avatarPlaceholder: {
-
background: '#cbd5e1'
-
},
-
handle: {
-
color: '#64748b'
-
},
-
time: {
-
color: '#94a3b8'
-
},
-
text: {
-
color: '#0f172a'
-
},
-
facetTag: {
-
background: '#f1f5f9',
-
color: '#475569'
-
},
-
replyLine: {
-
color: '#475569'
-
},
-
replyLink: {
-
color: '#2563eb'
-
},
-
embedContainer: {
-
border: '1px solid #e2e8f0',
-
borderRadius: 12,
-
background: '#f8fafc'
-
},
-
postLink: {
-
color: '#2563eb'
-
}
-
},
-
dark: {
-
card: {
-
border: '1px solid #1e293b',
-
background: '#0f172a',
-
color: '#e2e8f0'
-
},
-
avatarPlaceholder: {
-
background: '#1e293b'
-
},
-
handle: {
-
color: '#cbd5f5'
-
},
-
time: {
-
color: '#94a3ff'
-
},
-
text: {
-
color: '#e2e8f0'
-
},
-
facetTag: {
-
background: '#1e293b',
-
color: '#e0f2fe'
-
},
-
replyLine: {
-
color: '#cbd5f5'
-
},
-
replyLink: {
-
color: '#38bdf8'
-
},
-
embedContainer: {
-
border: '1px solid #1e293b',
-
borderRadius: 12,
-
background: '#0b1120'
-
},
-
postLink: {
-
color: '#38bdf8'
-
}
-
}
-
} satisfies Record<'light' | 'dark', Record<string, React.CSSProperties>>;
-
function formatReplyLabel(target: ParsedAtUri, resolvedHandle?: string, loading?: boolean): string {
-
if (resolvedHandle) return `@${resolvedHandle}`;
-
if (loading) return '…';
-
return `@${formatDidForLabel(target.did)}`;
}
-
function createAutoEmbed(record: FeedPostRecord, authorDid: string | undefined, scheme: 'light' | 'dark'): React.ReactNode {
-
const embed = record.embed as { $type?: string } | undefined;
-
if (!embed) return null;
-
if (embed.$type === 'app.bsky.embed.images') {
-
return <ImagesEmbed embed={embed as ImagesEmbedType} did={authorDid} scheme={scheme} />;
-
}
-
if (embed.$type === 'app.bsky.embed.recordWithMedia') {
-
const media = (embed as RecordWithMediaEmbed).media;
-
if (media?.$type === 'app.bsky.embed.images') {
-
return <ImagesEmbed embed={media as ImagesEmbedType} did={authorDid} scheme={scheme} />;
-
}
-
}
-
return null;
}
type ImagesEmbedType = {
-
$type: 'app.bsky.embed.images';
-
images: Array<{
-
alt?: string;
-
mime?: string;
-
size?: number;
-
image?: {
-
$type?: string;
-
ref?: { $link?: string };
-
cid?: string;
-
};
-
aspectRatio?: {
-
width: number;
-
height: number;
-
};
-
}>;
};
type RecordWithMediaEmbed = {
-
$type: 'app.bsky.embed.recordWithMedia';
-
record?: unknown;
-
media?: { $type?: string };
};
interface ImagesEmbedProps {
-
embed: ImagesEmbedType;
-
did?: string;
-
scheme: 'light' | 'dark';
}
const ImagesEmbed: React.FC<ImagesEmbedProps> = ({ embed, did, scheme }) => {
-
if (!embed.images || embed.images.length === 0) return null;
-
const palette = scheme === 'dark' ? imagesPalette.dark : imagesPalette.light;
-
const columns = embed.images.length > 1 ? 'repeat(auto-fit, minmax(160px, 1fr))' : '1fr';
-
return (
-
<div style={{ ...imagesBase.container, ...palette.container, gridTemplateColumns: columns }}>
-
{embed.images.map((image, idx) => (
-
<PostImage key={idx} image={image} did={did} scheme={scheme} />
-
))}
-
</div>
-
);
};
interface PostImageProps {
-
image: ImagesEmbedType['images'][number];
-
did?: string;
-
scheme: 'light' | 'dark';
}
const PostImage: React.FC<PostImageProps> = ({ image, did, scheme }) => {
-
const cid = image.image?.ref?.$link ?? image.image?.cid;
-
const { url, loading, error } = useBlob(did, cid);
-
const alt = image.alt?.trim() || 'Bluesky attachment';
-
const palette = scheme === 'dark' ? imagesPalette.dark : imagesPalette.light;
-
const aspect = image.aspectRatio && image.aspectRatio.height > 0
-
? `${image.aspectRatio.width} / ${image.aspectRatio.height}`
-
: undefined;
-
return (
-
<figure style={{ ...imagesBase.item, ...palette.item }}>
-
<div style={{ ...imagesBase.media, ...palette.media, aspectRatio: aspect }}>
-
{url ? (
-
<img src={url} alt={alt} style={imagesBase.img} />
-
) : (
-
<div style={{ ...imagesBase.placeholder, ...palette.placeholder }}>
-
{loading ? 'Loading image…' : error ? 'Image failed to load' : 'Image unavailable'}
-
</div>
-
)}
-
</div>
-
{image.alt && image.alt.trim().length > 0 && (
-
<figcaption style={{ ...imagesBase.caption, ...palette.caption }}>{image.alt}</figcaption>
-
)}
-
</figure>
-
);
};
const imagesBase = {
-
container: {
-
display: 'grid',
-
gap: 8,
-
width: '100%'
-
} satisfies React.CSSProperties,
-
item: {
-
margin: 0,
-
display: 'flex',
-
flexDirection: 'column',
-
gap: 4
-
} satisfies React.CSSProperties,
-
media: {
-
position: 'relative',
-
width: '100%',
-
borderRadius: 12,
-
overflow: 'hidden'
-
} satisfies React.CSSProperties,
-
img: {
-
width: '100%',
-
height: '100%',
-
objectFit: 'cover'
-
} satisfies React.CSSProperties,
-
placeholder: {
-
display: 'flex',
-
alignItems: 'center',
-
justifyContent: 'center',
-
width: '100%',
-
height: '100%'
-
} satisfies React.CSSProperties,
-
caption: {
-
fontSize: 12,
-
lineHeight: 1.3
-
} satisfies React.CSSProperties
};
const imagesPalette = {
-
light: {
-
container: {
-
padding: 0
-
} satisfies React.CSSProperties,
-
item: {},
-
media: {
-
background: '#e2e8f0'
-
} satisfies React.CSSProperties,
-
placeholder: {
-
color: '#475569'
-
} satisfies React.CSSProperties,
-
caption: {
-
color: '#475569'
-
} satisfies React.CSSProperties
-
},
-
dark: {
-
container: {
-
padding: 0
-
} satisfies React.CSSProperties,
-
item: {},
-
media: {
-
background: '#1e293b'
-
} satisfies React.CSSProperties,
-
placeholder: {
-
color: '#cbd5f5'
-
} satisfies React.CSSProperties,
-
caption: {
-
color: '#94a3b8'
-
} satisfies React.CSSProperties
-
}
} as const;
-
export default BlueskyPostRenderer;
···
+
import React from "react";
+
import type { FeedPostRecord } from "../types/bluesky";
+
import {
+
useColorScheme,
+
type ColorSchemePreference,
+
} from "../hooks/useColorScheme";
+
import {
+
parseAtUri,
+
toBlueskyPostUrl,
+
formatDidForLabel,
+
type ParsedAtUri,
+
} from "../utils/at-uri";
+
import { useDidResolution } from "../hooks/useDidResolution";
+
import { useBlob } from "../hooks/useBlob";
+
import { BlueskyIcon } from "../components/BlueskyIcon";
export interface BlueskyPostRendererProps {
+
record: FeedPostRecord;
+
loading: boolean;
+
error?: Error;
+
// Optionally pass in actor display info if pre-fetched
+
authorHandle?: string;
+
authorDisplayName?: string;
+
avatarUrl?: string;
+
colorScheme?: ColorSchemePreference;
+
authorDid?: string;
+
embed?: React.ReactNode;
+
iconPlacement?: "cardBottomRight" | "timestamp" | "linkInline";
+
showIcon?: boolean;
+
atUri?: string;
}
+
export const BlueskyPostRenderer: React.FC<BlueskyPostRendererProps> = ({
+
record,
+
loading,
+
error,
+
authorDisplayName,
+
authorHandle,
+
avatarUrl,
+
colorScheme = "system",
+
authorDid,
+
embed,
+
iconPlacement = "timestamp",
+
showIcon = true,
+
atUri,
+
}) => {
+
const scheme = useColorScheme(colorScheme);
+
const replyParentUri = record.reply?.parent?.uri;
+
const replyTarget = replyParentUri ? parseAtUri(replyParentUri) : undefined;
+
const { handle: parentHandle, loading: parentHandleLoading } =
+
useDidResolution(replyTarget?.did);
+
if (error)
+
return (
+
<div style={{ padding: 8, color: "crimson" }}>
+
Failed to load post.
+
</div>
+
);
+
if (loading && !record) return <div style={{ padding: 8 }}>Loading…</div>;
+
const palette = scheme === "dark" ? themeStyles.dark : themeStyles.light;
+
const text = record.text;
+
const createdDate = new Date(record.createdAt);
+
const created = createdDate.toLocaleString(undefined, {
+
dateStyle: "medium",
+
timeStyle: "short",
+
});
+
const primaryName = authorDisplayName || authorHandle || "…";
+
const replyHref = replyTarget ? toBlueskyPostUrl(replyTarget) : undefined;
+
const replyLabel = replyTarget
+
? formatReplyLabel(replyTarget, parentHandle, parentHandleLoading)
+
: undefined;
+
const makeIcon = () => (showIcon ? <BlueskyIcon size={16} /> : null);
+
const resolvedEmbed = embed ?? createAutoEmbed(record, authorDid, scheme);
+
const parsedSelf = atUri ? parseAtUri(atUri) : undefined;
+
const postUrl = parsedSelf ? toBlueskyPostUrl(parsedSelf) : undefined;
+
const cardPadding =
+
typeof baseStyles.card.padding === "number"
+
? baseStyles.card.padding
+
: 12;
+
const cardStyle: React.CSSProperties = {
+
...baseStyles.card,
+
...palette.card,
+
...(iconPlacement === "cardBottomRight" && showIcon
+
? { paddingBottom: cardPadding + 16 }
+
: {}),
+
};
+
return (
+
<article style={cardStyle} aria-busy={loading}>
+
<header style={baseStyles.header}>
+
{avatarUrl ? (
+
<img
+
src={avatarUrl}
+
alt="avatar"
+
style={baseStyles.avatarImg}
+
/>
+
) : (
+
<div
+
style={{
+
...baseStyles.avatarPlaceholder,
+
...palette.avatarPlaceholder,
+
}}
+
aria-hidden
+
/>
+
)}
+
<div style={{ display: "flex", flexDirection: "column" }}>
+
<strong style={{ fontSize: 14 }}>{primaryName}</strong>
+
{authorDisplayName && authorHandle && (
+
<span
+
style={{ ...baseStyles.handle, ...palette.handle }}
+
>
+
@{authorHandle}
+
</span>
+
)}
+
</div>
+
{iconPlacement === "timestamp" && showIcon && (
+
<div style={baseStyles.headerIcon}>{makeIcon()}</div>
+
)}
+
</header>
+
{replyHref && replyLabel && (
+
<div style={{ ...baseStyles.replyLine, ...palette.replyLine }}>
+
Replying to{" "}
+
<a
+
href={replyHref}
+
target="_blank"
+
rel="noopener noreferrer"
+
style={{
+
...baseStyles.replyLink,
+
...palette.replyLink,
+
}}
+
>
+
{replyLabel}
+
</a>
+
</div>
+
)}
+
<div style={baseStyles.body}>
+
<p style={{ ...baseStyles.text, ...palette.text }}>{text}</p>
+
{record.facets && record.facets.length > 0 && (
+
<div style={baseStyles.facets}>
+
{record.facets.map((_, idx) => (
+
<span
+
key={idx}
+
style={{
+
...baseStyles.facetTag,
+
...palette.facetTag,
+
}}
+
>
+
facet
+
</span>
+
))}
+
</div>
+
)}
+
<div style={baseStyles.timestampRow}>
+
<time
+
style={{ ...baseStyles.time, ...palette.time }}
+
dateTime={record.createdAt}
+
>
+
{created}
+
</time>
+
{postUrl && (
+
<span style={baseStyles.linkWithIcon}>
+
<a
+
href={postUrl}
+
target="_blank"
+
rel="noopener noreferrer"
+
style={{
+
...baseStyles.postLink,
+
...palette.postLink,
+
}}
+
>
+
View on Bluesky
+
</a>
+
{iconPlacement === "linkInline" && showIcon && (
+
<span style={baseStyles.inlineIcon} aria-hidden>
+
{makeIcon()}
+
</span>
+
)}
+
</span>
+
)}
+
</div>
+
{resolvedEmbed && (
+
<div
+
style={{
+
...baseStyles.embedContainer,
+
...palette.embedContainer,
+
}}
+
>
+
{resolvedEmbed}
+
</div>
+
)}
+
</div>
+
{iconPlacement === "cardBottomRight" && showIcon && (
+
<div style={baseStyles.iconCorner} aria-hidden>
+
{makeIcon()}
+
</div>
+
)}
+
</article>
+
);
};
const baseStyles: Record<string, React.CSSProperties> = {
+
card: {
+
borderRadius: 12,
+
padding: 12,
+
fontFamily: "system-ui, sans-serif",
+
display: "flex",
+
flexDirection: "column",
+
gap: 8,
+
maxWidth: 600,
+
transition:
+
"background-color 180ms ease, border-color 180ms ease, color 180ms ease",
+
position: "relative",
+
},
+
header: {
+
display: "flex",
+
alignItems: "center",
+
gap: 8,
+
},
+
headerIcon: {
+
marginLeft: "auto",
+
display: "flex",
+
alignItems: "center",
+
},
+
avatarPlaceholder: {
+
width: 40,
+
height: 40,
+
borderRadius: "50%",
+
},
+
avatarImg: {
+
width: 40,
+
height: 40,
+
borderRadius: "50%",
+
objectFit: "cover",
+
},
+
handle: {
+
fontSize: 12,
+
},
+
time: {
+
fontSize: 11,
+
},
+
timestampIcon: {
+
display: "flex",
+
alignItems: "center",
+
justifyContent: "center",
+
},
+
body: {
+
fontSize: 14,
+
lineHeight: 1.4,
+
},
+
text: {
+
margin: 0,
+
whiteSpace: "pre-wrap",
+
overflowWrap: "anywhere",
+
},
+
facets: {
+
marginTop: 8,
+
display: "flex",
+
gap: 4,
+
},
+
embedContainer: {
+
marginTop: 12,
+
padding: 8,
+
borderRadius: 12,
+
display: "flex",
+
flexDirection: "column",
+
gap: 8,
+
},
+
timestampRow: {
+
display: "flex",
+
justifyContent: "flex-end",
+
alignItems: "center",
+
gap: 12,
+
marginTop: 12,
+
flexWrap: "wrap",
+
},
+
linkWithIcon: {
+
display: "inline-flex",
+
alignItems: "center",
+
gap: 6,
+
},
+
postLink: {
+
fontSize: 11,
+
textDecoration: "none",
+
fontWeight: 600,
+
},
+
inlineIcon: {
+
display: "inline-flex",
+
alignItems: "center",
+
},
+
facetTag: {
+
padding: "2px 6px",
+
borderRadius: 4,
+
fontSize: 11,
+
},
+
replyLine: {
+
fontSize: 12,
+
},
+
replyLink: {
+
textDecoration: "none",
+
fontWeight: 500,
+
},
+
iconCorner: {
+
position: "absolute",
+
right: 12,
+
bottom: 12,
+
display: "flex",
+
alignItems: "center",
+
justifyContent: "flex-end",
+
},
};
const themeStyles = {
+
light: {
+
card: {
+
border: "1px solid #e2e8f0",
+
background: "#ffffff",
+
color: "#0f172a",
+
},
+
avatarPlaceholder: {
+
background: "#cbd5e1",
+
},
+
handle: {
+
color: "#64748b",
+
},
+
time: {
+
color: "#94a3b8",
+
},
+
text: {
+
color: "#0f172a",
+
},
+
facetTag: {
+
background: "#f1f5f9",
+
color: "#475569",
+
},
+
replyLine: {
+
color: "#475569",
+
},
+
replyLink: {
+
color: "#2563eb",
+
},
+
embedContainer: {
+
border: "1px solid #e2e8f0",
+
borderRadius: 12,
+
background: "#f8fafc",
+
},
+
postLink: {
+
color: "#2563eb",
+
},
+
},
+
dark: {
+
card: {
+
border: "1px solid #1e293b",
+
background: "#0f172a",
+
color: "#e2e8f0",
+
},
+
avatarPlaceholder: {
+
background: "#1e293b",
+
},
+
handle: {
+
color: "#cbd5f5",
+
},
+
time: {
+
color: "#94a3ff",
+
},
+
text: {
+
color: "#e2e8f0",
+
},
+
facetTag: {
+
background: "#1e293b",
+
color: "#e0f2fe",
+
},
+
replyLine: {
+
color: "#cbd5f5",
+
},
+
replyLink: {
+
color: "#38bdf8",
+
},
+
embedContainer: {
+
border: "1px solid #1e293b",
+
borderRadius: 12,
+
background: "#0b1120",
+
},
+
postLink: {
+
color: "#38bdf8",
+
},
+
},
+
} satisfies Record<"light" | "dark", Record<string, React.CSSProperties>>;
+
function formatReplyLabel(
+
target: ParsedAtUri,
+
resolvedHandle?: string,
+
loading?: boolean,
+
): string {
+
if (resolvedHandle) return `@${resolvedHandle}`;
+
if (loading) return "…";
+
return `@${formatDidForLabel(target.did)}`;
}
+
function createAutoEmbed(
+
record: FeedPostRecord,
+
authorDid: string | undefined,
+
scheme: "light" | "dark",
+
): React.ReactNode {
+
const embed = record.embed as { $type?: string } | undefined;
+
if (!embed) return null;
+
if (embed.$type === "app.bsky.embed.images") {
+
return (
+
<ImagesEmbed
+
embed={embed as ImagesEmbedType}
+
did={authorDid}
+
scheme={scheme}
+
/>
+
);
+
}
+
if (embed.$type === "app.bsky.embed.recordWithMedia") {
+
const media = (embed as RecordWithMediaEmbed).media;
+
if (media?.$type === "app.bsky.embed.images") {
+
return (
+
<ImagesEmbed
+
embed={media as ImagesEmbedType}
+
did={authorDid}
+
scheme={scheme}
+
/>
+
);
+
}
+
}
+
return null;
}
type ImagesEmbedType = {
+
$type: "app.bsky.embed.images";
+
images: Array<{
+
alt?: string;
+
mime?: string;
+
size?: number;
+
image?: {
+
$type?: string;
+
ref?: { $link?: string };
+
cid?: string;
+
};
+
aspectRatio?: {
+
width: number;
+
height: number;
+
};
+
}>;
};
type RecordWithMediaEmbed = {
+
$type: "app.bsky.embed.recordWithMedia";
+
record?: unknown;
+
media?: { $type?: string };
};
interface ImagesEmbedProps {
+
embed: ImagesEmbedType;
+
did?: string;
+
scheme: "light" | "dark";
}
const ImagesEmbed: React.FC<ImagesEmbedProps> = ({ embed, did, scheme }) => {
+
if (!embed.images || embed.images.length === 0) return null;
+
const palette =
+
scheme === "dark" ? imagesPalette.dark : imagesPalette.light;
+
const columns =
+
embed.images.length > 1
+
? "repeat(auto-fit, minmax(160px, 1fr))"
+
: "1fr";
+
return (
+
<div
+
style={{
+
...imagesBase.container,
+
...palette.container,
+
gridTemplateColumns: columns,
+
}}
+
>
+
{embed.images.map((image, idx) => (
+
<PostImage key={idx} image={image} did={did} scheme={scheme} />
+
))}
+
</div>
+
);
};
interface PostImageProps {
+
image: ImagesEmbedType["images"][number];
+
did?: string;
+
scheme: "light" | "dark";
}
const PostImage: React.FC<PostImageProps> = ({ image, did, scheme }) => {
+
const cid = image.image?.ref?.$link ?? image.image?.cid;
+
const { url, loading, error } = useBlob(did, cid);
+
const alt = image.alt?.trim() || "Bluesky attachment";
+
const palette =
+
scheme === "dark" ? imagesPalette.dark : imagesPalette.light;
+
const aspect =
+
image.aspectRatio && image.aspectRatio.height > 0
+
? `${image.aspectRatio.width} / ${image.aspectRatio.height}`
+
: undefined;
+
return (
+
<figure style={{ ...imagesBase.item, ...palette.item }}>
+
<div
+
style={{
+
...imagesBase.media,
+
...palette.media,
+
aspectRatio: aspect,
+
}}
+
>
+
{url ? (
+
<img src={url} alt={alt} style={imagesBase.img} />
+
) : (
+
<div
+
style={{
+
...imagesBase.placeholder,
+
...palette.placeholder,
+
}}
+
>
+
{loading
+
? "Loading image…"
+
: error
+
? "Image failed to load"
+
: "Image unavailable"}
+
</div>
+
)}
+
</div>
+
{image.alt && image.alt.trim().length > 0 && (
+
<figcaption
+
style={{ ...imagesBase.caption, ...palette.caption }}
+
>
+
{image.alt}
+
</figcaption>
+
)}
+
</figure>
+
);
};
const imagesBase = {
+
container: {
+
display: "grid",
+
gap: 8,
+
width: "100%",
+
} satisfies React.CSSProperties,
+
item: {
+
margin: 0,
+
display: "flex",
+
flexDirection: "column",
+
gap: 4,
+
} satisfies React.CSSProperties,
+
media: {
+
position: "relative",
+
width: "100%",
+
borderRadius: 12,
+
overflow: "hidden",
+
} satisfies React.CSSProperties,
+
img: {
+
width: "100%",
+
height: "100%",
+
objectFit: "cover",
+
} satisfies React.CSSProperties,
+
placeholder: {
+
display: "flex",
+
alignItems: "center",
+
justifyContent: "center",
+
width: "100%",
+
height: "100%",
+
} satisfies React.CSSProperties,
+
caption: {
+
fontSize: 12,
+
lineHeight: 1.3,
+
} satisfies React.CSSProperties,
};
const imagesPalette = {
+
light: {
+
container: {
+
padding: 0,
+
} satisfies React.CSSProperties,
+
item: {},
+
media: {
+
background: "#e2e8f0",
+
} satisfies React.CSSProperties,
+
placeholder: {
+
color: "#475569",
+
} satisfies React.CSSProperties,
+
caption: {
+
color: "#475569",
+
} satisfies React.CSSProperties,
+
},
+
dark: {
+
container: {
+
padding: 0,
+
} satisfies React.CSSProperties,
+
item: {},
+
media: {
+
background: "#1e293b",
+
} satisfies React.CSSProperties,
+
placeholder: {
+
color: "#cbd5f5",
+
} satisfies React.CSSProperties,
+
caption: {
+
color: "#94a3b8",
+
} satisfies React.CSSProperties,
+
},
} as const;
+
export default BlueskyPostRenderer;
+232 -176
lib/renderers/BlueskyProfileRenderer.tsx
···
-
import React from 'react';
-
import type { ProfileRecord } from '../types/bluesky';
-
import { useColorScheme, type ColorSchemePreference } from '../hooks/useColorScheme';
-
import { BlueskyIcon } from '../components/BlueskyIcon';
export interface BlueskyProfileRendererProps {
-
record: ProfileRecord;
-
loading: boolean;
-
error?: Error;
-
did: string;
-
handle?: string;
-
avatarUrl?: string;
-
colorScheme?: ColorSchemePreference;
}
-
export const BlueskyProfileRenderer: React.FC<BlueskyProfileRendererProps> = ({ record, loading, error, did, handle, avatarUrl, colorScheme = 'system' }) => {
-
const scheme = useColorScheme(colorScheme);
-
if (error) return <div style={{ padding: 8, color: 'crimson' }}>Failed to load profile.</div>;
-
if (loading && !record) return <div style={{ padding: 8 }}>Loading…</div>;
-
const palette = scheme === 'dark' ? theme.dark : theme.light;
-
const profileUrl = `https://bsky.app/profile/${encodeURIComponent(did)}`;
-
const rawWebsite = record.website?.trim();
-
const websiteHref = rawWebsite ? (rawWebsite.match(/^https?:\/\//i) ? rawWebsite : `https://${rawWebsite}`) : undefined;
-
const websiteLabel = rawWebsite ? rawWebsite.replace(/^https?:\/\//i, '') : undefined;
-
return (
-
<div style={{ ...base.card, ...palette.card }}>
-
<div style={base.header}>
-
{avatarUrl ? <img src={avatarUrl} alt="avatar" style={base.avatarImg} /> : <div style={{ ...base.avatar, ...palette.avatar }} aria-label="avatar" />}
-
<div style={{ flex: 1 }}>
-
<div style={{ ...base.display, ...palette.display }}>{record.displayName ?? handle ?? did}</div>
-
<div style={{ ...base.handleLine, ...palette.handleLine }}>@{handle ?? did}</div>
-
{record.pronouns && <div style={{ ...base.pronouns, ...palette.pronouns }}>{record.pronouns}</div>}
-
</div>
-
</div>
-
{record.description && <p style={{ ...base.desc, ...palette.desc }}>{record.description}</p>}
-
{record.createdAt && <div style={{ ...base.meta, ...palette.meta }}>Joined {new Date(record.createdAt).toLocaleDateString()}</div>}
-
<div style={base.links}>
-
{websiteHref && websiteLabel && (
-
<a href={websiteHref} target="_blank" rel="noopener noreferrer" style={{ ...base.link, ...palette.link }}>
-
{websiteLabel}
-
</a>
-
)}
-
<a href={profileUrl} target="_blank" rel="noopener noreferrer" style={{ ...base.link, ...palette.link }}>
-
View on Bluesky
-
</a>
-
</div>
-
<div style={base.iconCorner} aria-hidden>
-
<BlueskyIcon size={18} />
-
</div>
-
</div>
-
);
};
const base: Record<string, React.CSSProperties> = {
-
card: {
-
borderRadius: 12,
-
padding: 16,
-
fontFamily: 'system-ui, sans-serif',
-
maxWidth: 480,
-
transition: 'background-color 180ms ease, border-color 180ms ease, color 180ms ease',
-
position: 'relative'
-
},
-
header: {
-
display: 'flex',
-
gap: 12,
-
marginBottom: 8
-
},
-
avatar: {
-
width: 64,
-
height: 64,
-
borderRadius: '50%'
-
},
-
avatarImg: {
-
width: 64,
-
height: 64,
-
borderRadius: '50%',
-
objectFit: 'cover'
-
},
-
display: {
-
fontSize: 20,
-
fontWeight: 600
-
},
-
handleLine: {
-
fontSize: 13
-
},
-
desc: {
-
whiteSpace: 'pre-wrap',
-
fontSize: 14,
-
lineHeight: 1.4
-
},
-
meta: {
-
marginTop: 12,
-
fontSize: 12
-
},
-
pronouns: {
-
display: 'inline-flex',
-
alignItems: 'center',
-
gap: 4,
-
fontSize: 12,
-
fontWeight: 500,
-
borderRadius: 999,
-
padding: '2px 8px',
-
marginTop: 6
-
},
-
links: {
-
display: 'flex',
-
flexDirection: 'column',
-
gap: 8,
-
marginTop: 12
-
},
-
link: {
-
display: 'inline-flex',
-
alignItems: 'center',
-
gap: 4,
-
fontSize: 12,
-
fontWeight: 600,
-
textDecoration: 'none'
-
},
-
iconCorner: {
-
position: 'absolute',
-
right: 12,
-
bottom: 12
-
}
};
const theme = {
-
light: {
-
card: {
-
border: '1px solid #e2e8f0',
-
background: '#ffffff',
-
color: '#0f172a'
-
},
-
avatar: {
-
background: '#cbd5e1'
-
},
-
display: {
-
color: '#0f172a'
-
},
-
handleLine: {
-
color: '#64748b'
-
},
-
desc: {
-
color: '#0f172a'
-
},
-
meta: {
-
color: '#94a3b8'
-
},
-
pronouns: {
-
background: '#e2e8f0',
-
color: '#1e293b'
-
},
-
link: {
-
color: '#2563eb'
-
}
-
},
-
dark: {
-
card: {
-
border: '1px solid #1e293b',
-
background: '#0b1120',
-
color: '#e2e8f0'
-
},
-
avatar: {
-
background: '#1e293b'
-
},
-
display: {
-
color: '#e2e8f0'
-
},
-
handleLine: {
-
color: '#cbd5f5'
-
},
-
desc: {
-
color: '#e2e8f0'
-
},
-
meta: {
-
color: '#a5b4fc'
-
},
-
pronouns: {
-
background: '#1e293b',
-
color: '#e2e8f0'
-
},
-
link: {
-
color: '#38bdf8'
-
}
-
}
-
} satisfies Record<'light' | 'dark', Record<string, React.CSSProperties>>;
-
export default BlueskyProfileRenderer;
···
+
import React from "react";
+
import type { ProfileRecord } from "../types/bluesky";
+
import {
+
useColorScheme,
+
type ColorSchemePreference,
+
} from "../hooks/useColorScheme";
+
import { BlueskyIcon } from "../components/BlueskyIcon";
export interface BlueskyProfileRendererProps {
+
record: ProfileRecord;
+
loading: boolean;
+
error?: Error;
+
did: string;
+
handle?: string;
+
avatarUrl?: string;
+
colorScheme?: ColorSchemePreference;
}
+
export const BlueskyProfileRenderer: React.FC<BlueskyProfileRendererProps> = ({
+
record,
+
loading,
+
error,
+
did,
+
handle,
+
avatarUrl,
+
colorScheme = "system",
+
}) => {
+
const scheme = useColorScheme(colorScheme);
+
if (error)
+
return (
+
<div style={{ padding: 8, color: "crimson" }}>
+
Failed to load profile.
+
</div>
+
);
+
if (loading && !record) return <div style={{ padding: 8 }}>Loading…</div>;
+
const palette = scheme === "dark" ? theme.dark : theme.light;
+
const profileUrl = `https://bsky.app/profile/${encodeURIComponent(did)}`;
+
const rawWebsite = record.website?.trim();
+
const websiteHref = rawWebsite
+
? rawWebsite.match(/^https?:\/\//i)
+
? rawWebsite
+
: `https://${rawWebsite}`
+
: undefined;
+
const websiteLabel = rawWebsite
+
? rawWebsite.replace(/^https?:\/\//i, "")
+
: undefined;
+
return (
+
<div style={{ ...base.card, ...palette.card }}>
+
<div style={base.header}>
+
{avatarUrl ? (
+
<img src={avatarUrl} alt="avatar" style={base.avatarImg} />
+
) : (
+
<div
+
style={{ ...base.avatar, ...palette.avatar }}
+
aria-label="avatar"
+
/>
+
)}
+
<div style={{ flex: 1 }}>
+
<div style={{ ...base.display, ...palette.display }}>
+
{record.displayName ?? handle ?? did}
+
</div>
+
<div style={{ ...base.handleLine, ...palette.handleLine }}>
+
@{handle ?? did}
+
</div>
+
{record.pronouns && (
+
<div style={{ ...base.pronouns, ...palette.pronouns }}>
+
{record.pronouns}
+
</div>
+
)}
+
</div>
+
</div>
+
{record.description && (
+
<p style={{ ...base.desc, ...palette.desc }}>
+
{record.description}
+
</p>
+
)}
+
{record.createdAt && (
+
<div style={{ ...base.meta, ...palette.meta }}>
+
Joined {new Date(record.createdAt).toLocaleDateString()}
+
</div>
+
)}
+
<div style={base.links}>
+
{websiteHref && websiteLabel && (
+
<a
+
href={websiteHref}
+
target="_blank"
+
rel="noopener noreferrer"
+
style={{ ...base.link, ...palette.link }}
+
>
+
{websiteLabel}
+
</a>
+
)}
+
<a
+
href={profileUrl}
+
target="_blank"
+
rel="noopener noreferrer"
+
style={{ ...base.link, ...palette.link }}
+
>
+
View on Bluesky
+
</a>
+
</div>
+
<div style={base.iconCorner} aria-hidden>
+
<BlueskyIcon size={18} />
+
</div>
+
</div>
+
);
};
const base: Record<string, React.CSSProperties> = {
+
card: {
+
borderRadius: 12,
+
padding: 16,
+
fontFamily: "system-ui, sans-serif",
+
maxWidth: 480,
+
transition:
+
"background-color 180ms ease, border-color 180ms ease, color 180ms ease",
+
position: "relative",
+
},
+
header: {
+
display: "flex",
+
gap: 12,
+
marginBottom: 8,
+
},
+
avatar: {
+
width: 64,
+
height: 64,
+
borderRadius: "50%",
+
},
+
avatarImg: {
+
width: 64,
+
height: 64,
+
borderRadius: "50%",
+
objectFit: "cover",
+
},
+
display: {
+
fontSize: 20,
+
fontWeight: 600,
+
},
+
handleLine: {
+
fontSize: 13,
+
},
+
desc: {
+
whiteSpace: "pre-wrap",
+
fontSize: 14,
+
lineHeight: 1.4,
+
},
+
meta: {
+
marginTop: 12,
+
fontSize: 12,
+
},
+
pronouns: {
+
display: "inline-flex",
+
alignItems: "center",
+
gap: 4,
+
fontSize: 12,
+
fontWeight: 500,
+
borderRadius: 999,
+
padding: "2px 8px",
+
marginTop: 6,
+
},
+
links: {
+
display: "flex",
+
flexDirection: "column",
+
gap: 8,
+
marginTop: 12,
+
},
+
link: {
+
display: "inline-flex",
+
alignItems: "center",
+
gap: 4,
+
fontSize: 12,
+
fontWeight: 600,
+
textDecoration: "none",
+
},
+
iconCorner: {
+
position: "absolute",
+
right: 12,
+
bottom: 12,
+
},
};
const theme = {
+
light: {
+
card: {
+
border: "1px solid #e2e8f0",
+
background: "#ffffff",
+
color: "#0f172a",
+
},
+
avatar: {
+
background: "#cbd5e1",
+
},
+
display: {
+
color: "#0f172a",
+
},
+
handleLine: {
+
color: "#64748b",
+
},
+
desc: {
+
color: "#0f172a",
+
},
+
meta: {
+
color: "#94a3b8",
+
},
+
pronouns: {
+
background: "#e2e8f0",
+
color: "#1e293b",
+
},
+
link: {
+
color: "#2563eb",
+
},
+
},
+
dark: {
+
card: {
+
border: "1px solid #1e293b",
+
background: "#0b1120",
+
color: "#e2e8f0",
+
},
+
avatar: {
+
background: "#1e293b",
+
},
+
display: {
+
color: "#e2e8f0",
+
},
+
handleLine: {
+
color: "#cbd5f5",
+
},
+
desc: {
+
color: "#e2e8f0",
+
},
+
meta: {
+
color: "#a5b4fc",
+
},
+
pronouns: {
+
background: "#1e293b",
+
color: "#e2e8f0",
+
},
+
link: {
+
color: "#38bdf8",
+
},
+
},
+
} satisfies Record<"light" | "dark", Record<string, React.CSSProperties>>;
+
export default BlueskyProfileRenderer;
+1320 -856
lib/renderers/LeafletDocumentRenderer.tsx
···
-
import React, { useMemo, useRef } from 'react';
-
import { useColorScheme, type ColorSchemePreference } from '../hooks/useColorScheme';
-
import { useDidResolution } from '../hooks/useDidResolution';
-
import { useBlob } from '../hooks/useBlob';
-
import { parseAtUri, formatDidForLabel, toBlueskyPostUrl, leafletRkeyUrl, normalizeLeafletBasePath } from '../utils/at-uri';
-
import { BlueskyPost } from '../components/BlueskyPost';
import type {
-
LeafletDocumentRecord,
-
LeafletLinearDocumentPage,
-
LeafletLinearDocumentBlock,
-
LeafletBlock,
-
LeafletTextBlock,
-
LeafletHeaderBlock,
-
LeafletBlockquoteBlock,
-
LeafletImageBlock,
-
LeafletUnorderedListBlock,
-
LeafletListItem,
-
LeafletWebsiteBlock,
-
LeafletIFrameBlock,
-
LeafletMathBlock,
-
LeafletCodeBlock,
-
LeafletBskyPostBlock,
-
LeafletAlignmentValue,
-
LeafletRichTextFacet,
-
LeafletRichTextFeature,
-
LeafletPublicationRecord
-
} from '../types/leaflet';
export interface LeafletDocumentRendererProps {
-
record: LeafletDocumentRecord;
-
loading: boolean;
-
error?: Error;
-
colorScheme?: ColorSchemePreference;
-
did: string;
-
rkey: string;
-
canonicalUrl?: string;
-
publicationBaseUrl?: string;
-
publicationRecord?: LeafletPublicationRecord;
}
-
export const LeafletDocumentRenderer: React.FC<LeafletDocumentRendererProps> = ({ record, loading, error, colorScheme = 'system', did, rkey, canonicalUrl, publicationBaseUrl, publicationRecord }) => {
-
const scheme = useColorScheme(colorScheme);
-
const palette = scheme === 'dark' ? theme.dark : theme.light;
-
const authorDid = record.author?.startsWith('did:') ? record.author : undefined;
-
const publicationUri = useMemo(() => parseAtUri(record.publication), [record.publication]);
-
const postUrl = useMemo(() => {
-
const postRefUri = record.postRef?.uri;
-
if (!postRefUri) return undefined;
-
const parsed = parseAtUri(postRefUri);
-
return parsed ? toBlueskyPostUrl(parsed) : undefined;
-
}, [record.postRef?.uri]);
-
const { handle: publicationHandle } = useDidResolution(publicationUri?.did);
-
const fallbackAuthorLabel = useAuthorLabel(record.author, authorDid);
-
const resolvedPublicationLabel = publicationRecord?.name?.trim()
-
?? (publicationHandle ? `@${publicationHandle}` : publicationUri ? formatDidForLabel(publicationUri.did) : undefined);
-
const authorLabel = resolvedPublicationLabel ?? fallbackAuthorLabel;
-
const authorHref = publicationUri ? `https://bsky.app/profile/${publicationUri.did}` : undefined;
-
if (error) return <div style={{ padding: 12, color: 'crimson' }}>Failed to load leaflet.</div>;
-
if (loading && !record) return <div style={{ padding: 12 }}>Loading leaflet…</div>;
-
if (!record) return <div style={{ padding: 12, color: 'crimson' }}>Leaflet record missing.</div>;
-
const publishedAt = record.publishedAt ? new Date(record.publishedAt) : undefined;
-
const publishedLabel = publishedAt ? publishedAt.toLocaleString(undefined, { dateStyle: 'long', timeStyle: 'short' }) : undefined;
-
const fallbackLeafletUrl = `https://bsky.app/leaflet/${encodeURIComponent(did)}/${encodeURIComponent(rkey)}`;
-
const publicationRoot = publicationBaseUrl ?? (publicationRecord?.base_path ?? undefined);
-
const resolvedPublicationRoot = publicationRoot ? normalizeLeafletBasePath(publicationRoot) : undefined;
-
const publicationLeafletUrl = leafletRkeyUrl(publicationRoot, rkey);
-
const viewUrl = canonicalUrl ?? publicationLeafletUrl ?? postUrl ?? (publicationUri ? `https://bsky.app/profile/${publicationUri.did}` : undefined) ?? fallbackLeafletUrl;
-
const metaItems: React.ReactNode[] = [];
-
if (authorLabel) {
-
const authorNode = authorHref
-
? (
-
<a href={authorHref} target="_blank" rel="noopener noreferrer" style={palette.metaLink}>
-
{authorLabel}
-
</a>
-
)
-
: authorLabel;
-
metaItems.push(<span>By {authorNode}</span>);
-
}
-
if (publishedLabel) metaItems.push(<time dateTime={record.publishedAt}>{publishedLabel}</time>);
-
if (resolvedPublicationRoot) {
-
metaItems.push(
-
<a href={resolvedPublicationRoot} target="_blank" rel="noopener noreferrer" style={palette.metaLink}>
-
{resolvedPublicationRoot.replace(/^https?:\/\//, '')}
-
</a>
-
);
-
}
-
if (viewUrl) {
-
metaItems.push(
-
<a href={viewUrl} target="_blank" rel="noopener noreferrer" style={palette.metaLink}>
-
View source
-
</a>
-
);
-
}
-
return (
-
<article style={{ ...base.container, ...palette.container }}>
-
<header style={{ ...base.header, ...palette.header }}>
-
<div style={base.headerContent}>
-
<h1 style={{ ...base.title, ...palette.title }}>{record.title}</h1>
-
{record.description && (
-
<p style={{ ...base.subtitle, ...palette.subtitle }}>{record.description}</p>
-
)}
-
</div>
-
<div style={{ ...base.meta, ...palette.meta }}>
-
{metaItems.map((item, idx) => (
-
<React.Fragment key={`meta-${idx}`}>
-
{idx > 0 && <span style={palette.metaSeparator}>•</span>}
-
{item}
-
</React.Fragment>
-
))}
-
</div>
-
</header>
-
<div style={base.body}>
-
{record.pages?.map((page, pageIndex) => (
-
<LeafletPageRenderer
-
key={`page-${pageIndex}`}
-
page={page}
-
documentDid={did}
-
colorScheme={scheme}
-
/>
-
))}
-
</div>
-
</article>
-
);
};
-
const LeafletPageRenderer: React.FC<{ page: LeafletLinearDocumentPage; documentDid: string; colorScheme: 'light' | 'dark' }> = ({ page, documentDid, colorScheme }) => {
-
if (!page.blocks?.length) return null;
-
return (
-
<div style={base.page}>
-
{page.blocks.map((blockWrapper, idx) => (
-
<LeafletBlockRenderer
-
key={`block-${idx}`}
-
wrapper={blockWrapper}
-
documentDid={documentDid}
-
colorScheme={colorScheme}
-
isFirst={idx === 0}
-
/>
-
))}
-
</div>
-
);
};
interface LeafletBlockRendererProps {
-
wrapper: LeafletLinearDocumentBlock;
-
documentDid: string;
-
colorScheme: 'light' | 'dark';
-
isFirst?: boolean;
}
-
const LeafletBlockRenderer: React.FC<LeafletBlockRendererProps> = ({ wrapper, documentDid, colorScheme, isFirst }) => {
-
const block = wrapper.block;
-
if (!block || !('$type' in block) || !block.$type) {
-
return null;
-
}
-
const alignment = alignmentValue(wrapper.alignment);
-
switch (block.$type) {
-
case 'pub.leaflet.blocks.header':
-
return <LeafletHeaderBlockView block={block} alignment={alignment} colorScheme={colorScheme} isFirst={isFirst} />;
-
case 'pub.leaflet.blocks.blockquote':
-
return <LeafletBlockquoteBlockView block={block} alignment={alignment} colorScheme={colorScheme} isFirst={isFirst} />;
-
case 'pub.leaflet.blocks.image':
-
return <LeafletImageBlockView block={block} alignment={alignment} documentDid={documentDid} colorScheme={colorScheme} />;
-
case 'pub.leaflet.blocks.unorderedList':
-
return <LeafletListBlockView block={block} alignment={alignment} documentDid={documentDid} colorScheme={colorScheme} />;
-
case 'pub.leaflet.blocks.website':
-
return <LeafletWebsiteBlockView block={block} alignment={alignment} documentDid={documentDid} colorScheme={colorScheme} />;
-
case 'pub.leaflet.blocks.iframe':
-
return <LeafletIframeBlockView block={block} alignment={alignment} />;
-
case 'pub.leaflet.blocks.math':
-
return <LeafletMathBlockView block={block} alignment={alignment} colorScheme={colorScheme} />;
-
case 'pub.leaflet.blocks.code':
-
return <LeafletCodeBlockView block={block} alignment={alignment} colorScheme={colorScheme} />;
-
case 'pub.leaflet.blocks.horizontalRule':
-
return <LeafletHorizontalRuleBlockView alignment={alignment} colorScheme={colorScheme} />;
-
case 'pub.leaflet.blocks.bskyPost':
-
return <LeafletBskyPostBlockView block={block} colorScheme={colorScheme} />;
-
case 'pub.leaflet.blocks.text':
-
default:
-
return <LeafletTextBlockView block={block as LeafletTextBlock} alignment={alignment} colorScheme={colorScheme} isFirst={isFirst} />;
-
}
};
-
const LeafletTextBlockView: React.FC<{ block: LeafletTextBlock; alignment?: React.CSSProperties['textAlign']; colorScheme: 'light' | 'dark'; isFirst?: boolean }> = ({ block, alignment, colorScheme, isFirst }) => {
-
const segments = useMemo(() => createFacetedSegments(block.plaintext, block.facets), [block.plaintext, block.facets]);
-
const textContent = block.plaintext ?? '';
-
if (!textContent.trim() && segments.length === 0) {
-
return null;
-
}
-
const palette = colorScheme === 'dark' ? theme.dark : theme.light;
-
const style: React.CSSProperties = {
-
...base.paragraph,
-
...palette.paragraph,
-
...(alignment ? { textAlign: alignment } : undefined),
-
...(isFirst ? { marginTop: 0 } : undefined)
-
};
-
return (
-
<p style={style}>
-
{segments.map((segment, idx) => (
-
<React.Fragment key={`text-${idx}`}>
-
{renderSegment(segment, colorScheme)}
-
</React.Fragment>
-
))}
-
</p>
-
);
};
-
const LeafletHeaderBlockView: React.FC<{ block: LeafletHeaderBlock; alignment?: React.CSSProperties['textAlign']; colorScheme: 'light' | 'dark'; isFirst?: boolean }> = ({ block, alignment, colorScheme, isFirst }) => {
-
const palette = colorScheme === 'dark' ? theme.dark : theme.light;
-
const level = block.level && block.level >= 1 && block.level <= 6 ? block.level : 2;
-
const segments = useMemo(() => createFacetedSegments(block.plaintext, block.facets), [block.plaintext, block.facets]);
-
const normalizedLevel = Math.min(Math.max(level, 1), 6) as 1 | 2 | 3 | 4 | 5 | 6;
-
const headingTag = (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as const)[normalizedLevel - 1];
-
const headingStyles = palette.heading[normalizedLevel];
-
const style: React.CSSProperties = {
-
...base.heading,
-
...headingStyles,
-
...(alignment ? { textAlign: alignment } : undefined),
-
...(isFirst ? { marginTop: 0 } : undefined)
-
};
-
return React.createElement(
-
headingTag,
-
{ style },
-
segments.map((segment, idx) => (
-
<React.Fragment key={`header-${idx}`}>
-
{renderSegment(segment, colorScheme)}
-
</React.Fragment>
-
))
-
);
};
-
const LeafletBlockquoteBlockView: React.FC<{ block: LeafletBlockquoteBlock; alignment?: React.CSSProperties['textAlign']; colorScheme: 'light' | 'dark'; isFirst?: boolean }> = ({ block, alignment, colorScheme, isFirst }) => {
-
const segments = useMemo(() => createFacetedSegments(block.plaintext, block.facets), [block.plaintext, block.facets]);
-
const textContent = block.plaintext ?? '';
-
if (!textContent.trim() && segments.length === 0) {
-
return null;
-
}
-
const palette = colorScheme === 'dark' ? theme.dark : theme.light;
-
return (
-
<blockquote style={{ ...base.blockquote, ...palette.blockquote, ...(alignment ? { textAlign: alignment } : undefined), ...(isFirst ? { marginTop: 0 } : undefined) }}>
-
{segments.map((segment, idx) => (
-
<React.Fragment key={`quote-${idx}`}>
-
{renderSegment(segment, colorScheme)}
-
</React.Fragment>
-
))}
-
</blockquote>
-
);
};
-
const LeafletImageBlockView: React.FC<{ block: LeafletImageBlock; alignment?: React.CSSProperties['textAlign']; documentDid: string; colorScheme: 'light' | 'dark' }> = ({ block, alignment, documentDid, colorScheme }) => {
-
const palette = colorScheme === 'dark' ? theme.dark : theme.light;
-
const cid = block.image?.ref?.$link ?? block.image?.cid;
-
const { url, loading, error } = useBlob(documentDid, cid);
-
const aspectRatio = block.aspectRatio?.height && block.aspectRatio?.width
-
? `${block.aspectRatio.width} / ${block.aspectRatio.height}`
-
: undefined;
-
return (
-
<figure style={{ ...base.figure, ...palette.figure, ...(alignment ? { textAlign: alignment } : undefined) }}>
-
<div style={{ ...base.imageWrapper, ...palette.imageWrapper, ...(aspectRatio ? { aspectRatio } : {}) }}>
-
{url && !error ? (
-
<img src={url} alt={block.alt ?? ''} style={{ ...base.image, ...palette.image }} />
-
) : (
-
<div style={{ ...base.imagePlaceholder, ...palette.imagePlaceholder }}>
-
{loading ? 'Loading image…' : error ? 'Image unavailable' : 'No image'}
-
</div>
-
)}
-
</div>
-
{block.alt && block.alt.trim().length > 0 && (
-
<figcaption style={{ ...base.caption, ...palette.caption }}>{block.alt}</figcaption>
-
)}
-
</figure>
-
);
};
-
const LeafletListBlockView: React.FC<{ block: LeafletUnorderedListBlock; alignment?: React.CSSProperties['textAlign']; documentDid: string; colorScheme: 'light' | 'dark' }> = ({ block, alignment, documentDid, colorScheme }) => {
-
const palette = colorScheme === 'dark' ? theme.dark : theme.light;
-
return (
-
<ul style={{ ...base.list, ...palette.list, ...(alignment ? { textAlign: alignment } : undefined) }}>
-
{block.children?.map((child, idx) => (
-
<LeafletListItemRenderer
-
key={`list-item-${idx}`}
-
item={child}
-
documentDid={documentDid}
-
colorScheme={colorScheme}
-
alignment={alignment}
-
/>
-
))}
-
</ul>
-
);
};
-
const LeafletListItemRenderer: React.FC<{ item: LeafletListItem; documentDid: string; colorScheme: 'light' | 'dark'; alignment?: React.CSSProperties['textAlign'] }> = ({ item, documentDid, colorScheme, alignment }) => {
-
return (
-
<li style={{ ...base.listItem, ...(alignment ? { textAlign: alignment } : undefined) }}>
-
<div>
-
<LeafletInlineBlock block={item.content} colorScheme={colorScheme} documentDid={documentDid} alignment={alignment} />
-
</div>
-
{item.children && item.children.length > 0 && (
-
<ul style={{ ...base.nestedList, ...(alignment ? { textAlign: alignment } : undefined) }}>
-
{item.children.map((child, idx) => (
-
<LeafletListItemRenderer key={`nested-${idx}`} item={child} documentDid={documentDid} colorScheme={colorScheme} alignment={alignment} />
-
))}
-
</ul>
-
)}
-
</li>
-
);
};
-
const LeafletInlineBlock: React.FC<{ block: LeafletBlock; colorScheme: 'light' | 'dark'; documentDid: string; alignment?: React.CSSProperties['textAlign'] }> = ({ block, colorScheme, documentDid, alignment }) => {
-
switch (block.$type) {
-
case 'pub.leaflet.blocks.header':
-
return <LeafletHeaderBlockView block={block as LeafletHeaderBlock} colorScheme={colorScheme} alignment={alignment} />;
-
case 'pub.leaflet.blocks.blockquote':
-
return <LeafletBlockquoteBlockView block={block as LeafletBlockquoteBlock} colorScheme={colorScheme} alignment={alignment} />;
-
case 'pub.leaflet.blocks.image':
-
return <LeafletImageBlockView block={block as LeafletImageBlock} documentDid={documentDid} colorScheme={colorScheme} alignment={alignment} />;
-
default:
-
return <LeafletTextBlockView block={block as LeafletTextBlock} colorScheme={colorScheme} alignment={alignment} />;
-
}
};
-
const LeafletWebsiteBlockView: React.FC<{ block: LeafletWebsiteBlock; alignment?: React.CSSProperties['textAlign']; documentDid: string; colorScheme: 'light' | 'dark' }> = ({ block, alignment, documentDid, colorScheme }) => {
-
const palette = colorScheme === 'dark' ? theme.dark : theme.light;
-
const previewCid = block.previewImage?.ref?.$link ?? block.previewImage?.cid;
-
const { url, loading, error } = useBlob(documentDid, previewCid);
-
return (
-
<a href={block.src} target="_blank" rel="noopener noreferrer" style={{ ...base.linkCard, ...palette.linkCard, ...(alignment ? { textAlign: alignment } : undefined) }}>
-
{url && !error ? (
-
<img src={url} alt={block.title ?? 'Website preview'} style={{ ...base.linkPreview, ...palette.linkPreview }} />
-
) : (
-
<div style={{ ...base.linkPreviewPlaceholder, ...palette.linkPreviewPlaceholder }}>
-
{loading ? 'Loading preview…' : 'Open link'}
-
</div>
-
)}
-
<div style={base.linkContent}>
-
{block.title && <strong style={palette.linkTitle}>{block.title}</strong>}
-
{block.description && <p style={palette.linkDescription}>{block.description}</p>}
-
<span style={palette.linkUrl}>{block.src}</span>
-
</div>
-
</a>
-
);
};
-
const LeafletIframeBlockView: React.FC<{ block: LeafletIFrameBlock; alignment?: React.CSSProperties['textAlign'] }> = ({ block, alignment }) => {
-
return (
-
<div style={{ ...(alignment ? { textAlign: alignment } : undefined) }}>
-
<iframe
-
src={block.url}
-
title={block.url}
-
style={{ ...base.iframe, ...(block.height ? { height: Math.min(Math.max(block.height, 120), 800) } : {}) }}
-
loading="lazy"
-
allowFullScreen
-
/>
-
</div>
-
);
};
-
const LeafletMathBlockView: React.FC<{ block: LeafletMathBlock; alignment?: React.CSSProperties['textAlign']; colorScheme: 'light' | 'dark' }> = ({ block, alignment, colorScheme }) => {
-
const palette = colorScheme === 'dark' ? theme.dark : theme.light;
-
return (
-
<pre style={{ ...base.math, ...palette.math, ...(alignment ? { textAlign: alignment } : undefined) }}>{block.tex}</pre>
-
);
};
-
const LeafletCodeBlockView: React.FC<{ block: LeafletCodeBlock; alignment?: React.CSSProperties['textAlign']; colorScheme: 'light' | 'dark' }> = ({ block, alignment, colorScheme }) => {
-
const palette = colorScheme === 'dark' ? theme.dark : theme.light;
-
const codeRef = useRef<HTMLElement | null>(null);
-
const langClass = block.language ? `language-${block.language.toLowerCase()}` : undefined;
-
return (
-
<pre style={{ ...base.code, ...palette.code, ...(alignment ? { textAlign: alignment } : undefined) }}>
-
<code ref={codeRef} className={langClass}>{block.plaintext}</code>
-
</pre>
-
);
};
-
const LeafletHorizontalRuleBlockView: React.FC<{ alignment?: React.CSSProperties['textAlign']; colorScheme: 'light' | 'dark' }> = ({ alignment, colorScheme }) => {
-
const palette = colorScheme === 'dark' ? theme.dark : theme.light;
-
return <hr style={{ ...base.hr, ...palette.hr, marginLeft: alignment ? 'auto' : undefined, marginRight: alignment ? 'auto' : undefined }} />;
};
-
const LeafletBskyPostBlockView: React.FC<{ block: LeafletBskyPostBlock; colorScheme: 'light' | 'dark' }> = ({ block, colorScheme }) => {
-
const parsed = parseAtUri(block.postRef?.uri);
-
if (!parsed) {
-
return <div style={base.embedFallback}>Referenced post unavailable.</div>;
-
}
-
return <BlueskyPost did={parsed.did} rkey={parsed.rkey} colorScheme={colorScheme} iconPlacement="linkInline" />;
};
-
function alignmentValue(value?: LeafletAlignmentValue): React.CSSProperties['textAlign'] | undefined {
-
if (!value) return undefined;
-
let normalized = value.startsWith('#') ? value.slice(1) : value;
-
if (normalized.includes('#')) {
-
normalized = normalized.split('#').pop() ?? normalized;
-
}
-
if (normalized.startsWith('lex:')) {
-
normalized = normalized.split(':').pop() ?? normalized;
-
}
-
switch (normalized) {
-
case 'textAlignLeft':
-
return 'left';
-
case 'textAlignCenter':
-
return 'center';
-
case 'textAlignRight':
-
return 'right';
-
case 'textAlignJustify':
-
return 'justify';
-
default:
-
return undefined;
-
}
}
-
function useAuthorLabel(author: string | undefined, authorDid: string | undefined): string | undefined {
-
const { handle } = useDidResolution(authorDid);
-
if (!author) return undefined;
-
if (handle) return `@${handle}`;
-
if (authorDid) return formatDidForLabel(authorDid);
-
return author;
}
interface Segment {
-
text: string;
-
features: LeafletRichTextFeature[];
}
-
function createFacetedSegments(plaintext: string, facets?: LeafletRichTextFacet[]): Segment[] {
-
if (!facets?.length) {
-
return [{ text: plaintext, features: [] }];
-
}
-
const prefix = buildBytePrefix(plaintext);
-
const startEvents = new Map<number, LeafletRichTextFeature[]>();
-
const endEvents = new Map<number, LeafletRichTextFeature[]>();
-
const boundaries = new Set<number>([0, prefix.length - 1]);
-
for (const facet of facets) {
-
const { byteStart, byteEnd } = facet.index ?? {};
-
if (typeof byteStart !== 'number' || typeof byteEnd !== 'number' || byteStart >= byteEnd) continue;
-
const start = byteOffsetToCharIndex(prefix, byteStart);
-
const end = byteOffsetToCharIndex(prefix, byteEnd);
-
if (start >= end) continue;
-
boundaries.add(start);
-
boundaries.add(end);
-
if (facet.features?.length) {
-
startEvents.set(start, [...(startEvents.get(start) ?? []), ...facet.features]);
-
endEvents.set(end, [...(endEvents.get(end) ?? []), ...facet.features]);
-
}
-
}
-
const sortedBounds = [...boundaries].sort((a, b) => a - b);
-
const segments: Segment[] = [];
-
let active: LeafletRichTextFeature[] = [];
-
for (let i = 0; i < sortedBounds.length - 1; i++) {
-
const boundary = sortedBounds[i];
-
const next = sortedBounds[i + 1];
-
const endFeatures = endEvents.get(boundary);
-
if (endFeatures?.length) {
-
active = active.filter((feature) => !endFeatures.includes(feature));
-
}
-
const startFeatures = startEvents.get(boundary);
-
if (startFeatures?.length) {
-
active = [...active, ...startFeatures];
-
}
-
if (boundary === next) continue;
-
const text = sliceByCharRange(plaintext, boundary, next);
-
segments.push({ text, features: active.slice() });
-
}
-
return segments;
}
function buildBytePrefix(text: string): number[] {
-
const encoder = new TextEncoder();
-
const prefix: number[] = [0];
-
let byteCount = 0;
-
for (let i = 0; i < text.length;) {
-
const codePoint = text.codePointAt(i)!;
-
const char = String.fromCodePoint(codePoint);
-
const encoded = encoder.encode(char);
-
byteCount += encoded.length;
-
prefix.push(byteCount);
-
i += codePoint > 0xffff ? 2 : 1;
-
}
-
return prefix;
}
function byteOffsetToCharIndex(prefix: number[], byteOffset: number): number {
-
for (let i = 0; i < prefix.length; i++) {
-
if (prefix[i] === byteOffset) return i;
-
if (prefix[i] > byteOffset) return Math.max(0, i - 1);
-
}
-
return prefix.length - 1;
}
function sliceByCharRange(text: string, start: number, end: number): string {
-
if (start <= 0 && end >= text.length) return text;
-
let result = '';
-
let charIndex = 0;
-
for (let i = 0; i < text.length && charIndex < end;) {
-
const codePoint = text.codePointAt(i)!;
-
const char = String.fromCodePoint(codePoint);
-
if (charIndex >= start && charIndex < end) result += char;
-
i += codePoint > 0xffff ? 2 : 1;
-
charIndex++;
-
}
-
return result;
}
-
function renderSegment(segment: Segment, colorScheme: 'light' | 'dark'): React.ReactNode {
-
const parts = segment.text.split('\n');
-
return parts.flatMap((part, idx) => {
-
const key = `${segment.text}-${idx}-${part.length}`;
-
const wrapped = applyFeatures(part.length ? part : '\u00a0', segment.features, key, colorScheme);
-
if (idx === parts.length - 1) return wrapped;
-
return [wrapped, <br key={`${key}-br`} />];
-
});
}
-
function applyFeatures(content: React.ReactNode, features: LeafletRichTextFeature[], key: string, colorScheme: 'light' | 'dark'): React.ReactNode {
-
if (!features?.length) return <React.Fragment key={key}>{content}</React.Fragment>;
-
return (
-
<React.Fragment key={key}>
-
{features.reduce<React.ReactNode>((child, feature, idx) => wrapFeature(child, feature, `${key}-feature-${idx}`, colorScheme), content)}
-
</React.Fragment>
-
);
}
-
function wrapFeature(child: React.ReactNode, feature: LeafletRichTextFeature, key: string, colorScheme: 'light' | 'dark'): React.ReactNode {
-
switch (feature.$type) {
-
case 'pub.leaflet.richtext.facet#link':
-
return <a key={key} href={feature.uri} target="_blank" rel="noopener noreferrer" style={linkStyles[colorScheme]}>{child}</a>;
-
case 'pub.leaflet.richtext.facet#code':
-
return <code key={key} style={inlineCodeStyles[colorScheme]}>{child}</code>;
-
case 'pub.leaflet.richtext.facet#highlight':
-
return <mark key={key} style={highlightStyles[colorScheme]}>{child}</mark>;
-
case 'pub.leaflet.richtext.facet#underline':
-
return <span key={key} style={{ textDecoration: 'underline' }}>{child}</span>;
-
case 'pub.leaflet.richtext.facet#strikethrough':
-
return <span key={key} style={{ textDecoration: 'line-through' }}>{child}</span>;
-
case 'pub.leaflet.richtext.facet#bold':
-
return <strong key={key}>{child}</strong>;
-
case 'pub.leaflet.richtext.facet#italic':
-
return <em key={key}>{child}</em>;
-
case 'pub.leaflet.richtext.facet#id':
-
return <span key={key} id={feature.id}>{child}</span>;
-
default:
-
return <span key={key}>{child}</span>;
-
}
}
const base: Record<string, React.CSSProperties> = {
-
container: {
-
display: 'flex',
-
flexDirection: 'column',
-
gap: 24,
-
padding: '24px 28px',
-
borderRadius: 20,
-
border: '1px solid transparent',
-
maxWidth: 720,
-
width: '100%',
-
fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
-
},
-
header: {
-
display: 'flex',
-
flexDirection: 'column',
-
gap: 16
-
},
-
headerContent: {
-
display: 'flex',
-
flexDirection: 'column',
-
gap: 8
-
},
-
title: {
-
fontSize: 32,
-
margin: 0,
-
lineHeight: 1.15
-
},
-
subtitle: {
-
margin: 0,
-
fontSize: 16,
-
lineHeight: 1.5
-
},
-
meta: {
-
display: 'flex',
-
flexWrap: 'wrap',
-
gap: 8,
-
alignItems: 'center',
-
fontSize: 14
-
},
-
body: {
-
display: 'flex',
-
flexDirection: 'column',
-
gap: 18
-
},
-
page: {
-
display: 'flex',
-
flexDirection: 'column',
-
gap: 18
-
},
-
paragraph: {
-
margin: '1em 0 0',
-
lineHeight: 1.65,
-
fontSize: 16
-
},
-
heading: {
-
margin: '0.5em 0 0',
-
fontWeight: 700
-
},
-
blockquote: {
-
margin: '1em 0 0',
-
padding: '0.6em 1em',
-
borderLeft: '4px solid'
-
},
-
figure: {
-
margin: '1.2em 0 0',
-
display: 'flex',
-
flexDirection: 'column',
-
gap: 12
-
},
-
imageWrapper: {
-
borderRadius: 16,
-
overflow: 'hidden',
-
width: '100%',
-
position: 'relative',
-
background: '#e2e8f0'
-
},
-
image: {
-
width: '100%',
-
height: '100%',
-
objectFit: 'cover',
-
display: 'block'
-
},
-
imagePlaceholder: {
-
width: '100%',
-
padding: '24px 16px',
-
textAlign: 'center'
-
},
-
caption: {
-
fontSize: 13,
-
lineHeight: 1.4
-
},
-
list: {
-
paddingLeft: 28,
-
margin: '1em 0 0',
-
listStyleType: 'disc',
-
listStylePosition: 'outside'
-
},
-
nestedList: {
-
paddingLeft: 20,
-
marginTop: 8,
-
listStyleType: 'circle',
-
listStylePosition: 'outside'
-
},
-
listItem: {
-
marginTop: 8,
-
display: 'list-item'
-
},
-
linkCard: {
-
borderRadius: 16,
-
border: '1px solid',
-
display: 'flex',
-
flexDirection: 'column',
-
overflow: 'hidden',
-
textDecoration: 'none'
-
},
-
linkPreview: {
-
width: '100%',
-
height: 180,
-
objectFit: 'cover'
-
},
-
linkPreviewPlaceholder: {
-
width: '100%',
-
height: 180,
-
display: 'flex',
-
alignItems: 'center',
-
justifyContent: 'center',
-
fontSize: 14
-
},
-
linkContent: {
-
display: 'flex',
-
flexDirection: 'column',
-
gap: 6,
-
padding: '16px 18px'
-
},
-
iframe: {
-
width: '100%',
-
height: 360,
-
border: '1px solid #cbd5f5',
-
borderRadius: 16
-
},
-
math: {
-
margin: '1em 0 0',
-
padding: '14px 16px',
-
borderRadius: 12,
-
fontFamily: 'Menlo, Consolas, "SFMono-Regular", ui-monospace',
-
overflowX: 'auto'
-
},
-
code: {
-
margin: '1em 0 0',
-
padding: '14px 16px',
-
borderRadius: 12,
-
overflowX: 'auto',
-
fontSize: 14
-
},
-
hr: {
-
border: 0,
-
borderTop: '1px solid',
-
margin: '24px 0 0'
-
},
-
embedFallback: {
-
padding: '12px 16px',
-
borderRadius: 12,
-
border: '1px solid #e2e8f0',
-
fontSize: 14
-
}
};
const theme = {
-
light: {
-
container: {
-
background: '#ffffff',
-
borderColor: '#e2e8f0',
-
color: '#0f172a',
-
boxShadow: '0 4px 18px rgba(15, 23, 42, 0.06)'
-
},
-
header: {},
-
title: {
-
color: '#0f172a'
-
},
-
subtitle: {
-
color: '#475569'
-
},
-
meta: {
-
color: '#64748b'
-
},
-
metaLink: {
-
color: '#2563eb',
-
textDecoration: 'none'
-
} satisfies React.CSSProperties,
-
metaSeparator: {
-
margin: '0 4px'
-
} satisfies React.CSSProperties,
-
paragraph: {
-
color: '#1f2937'
-
},
-
heading: {
-
1: { color: '#0f172a', fontSize: 30 },
-
2: { color: '#0f172a', fontSize: 28 },
-
3: { color: '#0f172a', fontSize: 24 },
-
4: { color: '#0f172a', fontSize: 20 },
-
5: { color: '#0f172a', fontSize: 18 },
-
6: { color: '#0f172a', fontSize: 16 }
-
} satisfies Record<number, React.CSSProperties>,
-
blockquote: {
-
background: '#f8fafc',
-
borderColor: '#cbd5f5',
-
color: '#1f2937'
-
},
-
figure: {},
-
imageWrapper: {
-
background: '#e2e8f0'
-
},
-
image: {},
-
imagePlaceholder: {
-
color: '#475569'
-
},
-
caption: {
-
color: '#475569'
-
},
-
list: {
-
color: '#1f2937'
-
},
-
linkCard: {
-
borderColor: '#e2e8f0',
-
background: '#f8fafc',
-
color: '#0f172a'
-
},
-
linkPreview: {},
-
linkPreviewPlaceholder: {
-
background: '#e2e8f0',
-
color: '#475569'
-
},
-
linkTitle: {
-
fontSize: 16,
-
color: '#0f172a'
-
} satisfies React.CSSProperties,
-
linkDescription: {
-
margin: 0,
-
fontSize: 14,
-
color: '#475569',
-
lineHeight: 1.5
-
} satisfies React.CSSProperties,
-
linkUrl: {
-
fontSize: 13,
-
color: '#2563eb',
-
wordBreak: 'break-all'
-
} satisfies React.CSSProperties,
-
math: {
-
background: '#f1f5f9',
-
color: '#1f2937',
-
border: '1px solid #e2e8f0'
-
},
-
code: {
-
background: '#0f172a',
-
color: '#e2e8f0'
-
},
-
hr: {
-
borderColor: '#e2e8f0'
-
}
-
},
-
dark: {
-
container: {
-
background: 'rgba(15, 23, 42, 0.6)',
-
borderColor: 'rgba(148, 163, 184, 0.3)',
-
color: '#e2e8f0',
-
backdropFilter: 'blur(8px)',
-
boxShadow: '0 10px 40px rgba(2, 6, 23, 0.45)'
-
},
-
header: {},
-
title: {
-
color: '#f8fafc'
-
},
-
subtitle: {
-
color: '#cbd5f5'
-
},
-
meta: {
-
color: '#94a3b8'
-
},
-
metaLink: {
-
color: '#38bdf8',
-
textDecoration: 'none'
-
} satisfies React.CSSProperties,
-
metaSeparator: {
-
margin: '0 4px'
-
} satisfies React.CSSProperties,
-
paragraph: {
-
color: '#e2e8f0'
-
},
-
heading: {
-
1: { color: '#f8fafc', fontSize: 30 },
-
2: { color: '#f8fafc', fontSize: 28 },
-
3: { color: '#f8fafc', fontSize: 24 },
-
4: { color: '#e2e8f0', fontSize: 20 },
-
5: { color: '#e2e8f0', fontSize: 18 },
-
6: { color: '#e2e8f0', fontSize: 16 }
-
} satisfies Record<number, React.CSSProperties>,
-
blockquote: {
-
background: 'rgba(30, 41, 59, 0.6)',
-
borderColor: '#38bdf8',
-
color: '#e2e8f0'
-
},
-
figure: {},
-
imageWrapper: {
-
background: '#1e293b'
-
},
-
image: {},
-
imagePlaceholder: {
-
color: '#94a3b8'
-
},
-
caption: {
-
color: '#94a3b8'
-
},
-
list: {
-
color: '#f1f5f9'
-
},
-
linkCard: {
-
borderColor: 'rgba(148, 163, 184, 0.3)',
-
background: 'rgba(15, 23, 42, 0.8)',
-
color: '#e2e8f0'
-
},
-
linkPreview: {},
-
linkPreviewPlaceholder: {
-
background: '#1e293b',
-
color: '#94a3b8'
-
},
-
linkTitle: {
-
fontSize: 16,
-
color: '#e0f2fe'
-
} satisfies React.CSSProperties,
-
linkDescription: {
-
margin: 0,
-
fontSize: 14,
-
color: '#cbd5f5',
-
lineHeight: 1.5
-
} satisfies React.CSSProperties,
-
linkUrl: {
-
fontSize: 13,
-
color: '#38bdf8',
-
wordBreak: 'break-all'
-
} satisfies React.CSSProperties,
-
math: {
-
background: 'rgba(15, 23, 42, 0.8)',
-
color: '#e2e8f0',
-
border: '1px solid rgba(148, 163, 184, 0.35)'
-
},
-
code: {
-
background: '#020617',
-
color: '#e2e8f0'
-
},
-
hr: {
-
borderColor: 'rgba(148, 163, 184, 0.3)'
-
}
-
}
} as const;
const linkStyles = {
-
light: {
-
color: '#2563eb',
-
textDecoration: 'underline'
-
} satisfies React.CSSProperties,
-
dark: {
-
color: '#38bdf8',
-
textDecoration: 'underline'
-
} satisfies React.CSSProperties
} as const;
const inlineCodeStyles = {
-
light: {
-
fontFamily: 'Menlo, Consolas, "SFMono-Regular", ui-monospace',
-
background: '#f1f5f9',
-
padding: '0 4px',
-
borderRadius: 4
-
} satisfies React.CSSProperties,
-
dark: {
-
fontFamily: 'Menlo, Consolas, "SFMono-Regular", ui-monospace',
-
background: '#1e293b',
-
padding: '0 4px',
-
borderRadius: 4
-
} satisfies React.CSSProperties
} as const;
const highlightStyles = {
-
light: {
-
background: '#fef08a'
-
} satisfies React.CSSProperties,
-
dark: {
-
background: '#facc15',
-
color: '#0f172a'
-
} satisfies React.CSSProperties
} as const;
export default LeafletDocumentRenderer;
···
+
import React, { useMemo, useRef } from "react";
+
import {
+
useColorScheme,
+
type ColorSchemePreference,
+
} from "../hooks/useColorScheme";
+
import { useDidResolution } from "../hooks/useDidResolution";
+
import { useBlob } from "../hooks/useBlob";
+
import {
+
parseAtUri,
+
formatDidForLabel,
+
toBlueskyPostUrl,
+
leafletRkeyUrl,
+
normalizeLeafletBasePath,
+
} from "../utils/at-uri";
+
import { BlueskyPost } from "../components/BlueskyPost";
import type {
+
LeafletDocumentRecord,
+
LeafletLinearDocumentPage,
+
LeafletLinearDocumentBlock,
+
LeafletBlock,
+
LeafletTextBlock,
+
LeafletHeaderBlock,
+
LeafletBlockquoteBlock,
+
LeafletImageBlock,
+
LeafletUnorderedListBlock,
+
LeafletListItem,
+
LeafletWebsiteBlock,
+
LeafletIFrameBlock,
+
LeafletMathBlock,
+
LeafletCodeBlock,
+
LeafletBskyPostBlock,
+
LeafletAlignmentValue,
+
LeafletRichTextFacet,
+
LeafletRichTextFeature,
+
LeafletPublicationRecord,
+
} from "../types/leaflet";
export interface LeafletDocumentRendererProps {
+
record: LeafletDocumentRecord;
+
loading: boolean;
+
error?: Error;
+
colorScheme?: ColorSchemePreference;
+
did: string;
+
rkey: string;
+
canonicalUrl?: string;
+
publicationBaseUrl?: string;
+
publicationRecord?: LeafletPublicationRecord;
}
+
export const LeafletDocumentRenderer: React.FC<
+
LeafletDocumentRendererProps
+
> = ({
+
record,
+
loading,
+
error,
+
colorScheme = "system",
+
did,
+
rkey,
+
canonicalUrl,
+
publicationBaseUrl,
+
publicationRecord,
+
}) => {
+
const scheme = useColorScheme(colorScheme);
+
const palette = scheme === "dark" ? theme.dark : theme.light;
+
const authorDid = record.author?.startsWith("did:")
+
? record.author
+
: undefined;
+
const publicationUri = useMemo(
+
() => parseAtUri(record.publication),
+
[record.publication],
+
);
+
const postUrl = useMemo(() => {
+
const postRefUri = record.postRef?.uri;
+
if (!postRefUri) return undefined;
+
const parsed = parseAtUri(postRefUri);
+
return parsed ? toBlueskyPostUrl(parsed) : undefined;
+
}, [record.postRef?.uri]);
+
const { handle: publicationHandle } = useDidResolution(publicationUri?.did);
+
const fallbackAuthorLabel = useAuthorLabel(record.author, authorDid);
+
const resolvedPublicationLabel =
+
publicationRecord?.name?.trim() ??
+
(publicationHandle
+
? `@${publicationHandle}`
+
: publicationUri
+
? formatDidForLabel(publicationUri.did)
+
: undefined);
+
const authorLabel = resolvedPublicationLabel ?? fallbackAuthorLabel;
+
const authorHref = publicationUri
+
? `https://bsky.app/profile/${publicationUri.did}`
+
: undefined;
+
if (error)
+
return (
+
<div style={{ padding: 12, color: "crimson" }}>
+
Failed to load leaflet.
+
</div>
+
);
+
if (loading && !record)
+
return <div style={{ padding: 12 }}>Loading leaflet…</div>;
+
if (!record)
+
return (
+
<div style={{ padding: 12, color: "crimson" }}>
+
Leaflet record missing.
+
</div>
+
);
+
const publishedAt = record.publishedAt
+
? new Date(record.publishedAt)
+
: undefined;
+
const publishedLabel = publishedAt
+
? publishedAt.toLocaleString(undefined, {
+
dateStyle: "long",
+
timeStyle: "short",
+
})
+
: undefined;
+
const fallbackLeafletUrl = `https://bsky.app/leaflet/${encodeURIComponent(did)}/${encodeURIComponent(rkey)}`;
+
const publicationRoot =
+
publicationBaseUrl ?? publicationRecord?.base_path ?? undefined;
+
const resolvedPublicationRoot = publicationRoot
+
? normalizeLeafletBasePath(publicationRoot)
+
: undefined;
+
const publicationLeafletUrl = leafletRkeyUrl(publicationRoot, rkey);
+
const viewUrl =
+
canonicalUrl ??
+
publicationLeafletUrl ??
+
postUrl ??
+
(publicationUri
+
? `https://bsky.app/profile/${publicationUri.did}`
+
: undefined) ??
+
fallbackLeafletUrl;
+
const metaItems: React.ReactNode[] = [];
+
if (authorLabel) {
+
const authorNode = authorHref ? (
+
<a
+
href={authorHref}
+
target="_blank"
+
rel="noopener noreferrer"
+
style={palette.metaLink}
+
>
+
{authorLabel}
+
</a>
+
) : (
+
authorLabel
+
);
+
metaItems.push(<span>By {authorNode}</span>);
+
}
+
if (publishedLabel)
+
metaItems.push(
+
<time dateTime={record.publishedAt}>{publishedLabel}</time>,
+
);
+
if (resolvedPublicationRoot) {
+
metaItems.push(
+
<a
+
href={resolvedPublicationRoot}
+
target="_blank"
+
rel="noopener noreferrer"
+
style={palette.metaLink}
+
>
+
{resolvedPublicationRoot.replace(/^https?:\/\//, "")}
+
</a>,
+
);
+
}
+
if (viewUrl) {
+
metaItems.push(
+
<a
+
href={viewUrl}
+
target="_blank"
+
rel="noopener noreferrer"
+
style={palette.metaLink}
+
>
+
View source
+
</a>,
+
);
+
}
+
return (
+
<article style={{ ...base.container, ...palette.container }}>
+
<header style={{ ...base.header, ...palette.header }}>
+
<div style={base.headerContent}>
+
<h1 style={{ ...base.title, ...palette.title }}>
+
{record.title}
+
</h1>
+
{record.description && (
+
<p style={{ ...base.subtitle, ...palette.subtitle }}>
+
{record.description}
+
</p>
+
)}
+
</div>
+
<div style={{ ...base.meta, ...palette.meta }}>
+
{metaItems.map((item, idx) => (
+
<React.Fragment key={`meta-${idx}`}>
+
{idx > 0 && (
+
<span style={palette.metaSeparator}>•</span>
+
)}
+
{item}
+
</React.Fragment>
+
))}
+
</div>
+
</header>
+
<div style={base.body}>
+
{record.pages?.map((page, pageIndex) => (
+
<LeafletPageRenderer
+
key={`page-${pageIndex}`}
+
page={page}
+
documentDid={did}
+
colorScheme={scheme}
+
/>
+
))}
+
</div>
+
</article>
+
);
};
+
const LeafletPageRenderer: React.FC<{
+
page: LeafletLinearDocumentPage;
+
documentDid: string;
+
colorScheme: "light" | "dark";
+
}> = ({ page, documentDid, colorScheme }) => {
+
if (!page.blocks?.length) return null;
+
return (
+
<div style={base.page}>
+
{page.blocks.map((blockWrapper, idx) => (
+
<LeafletBlockRenderer
+
key={`block-${idx}`}
+
wrapper={blockWrapper}
+
documentDid={documentDid}
+
colorScheme={colorScheme}
+
isFirst={idx === 0}
+
/>
+
))}
+
</div>
+
);
};
interface LeafletBlockRendererProps {
+
wrapper: LeafletLinearDocumentBlock;
+
documentDid: string;
+
colorScheme: "light" | "dark";
+
isFirst?: boolean;
}
+
const LeafletBlockRenderer: React.FC<LeafletBlockRendererProps> = ({
+
wrapper,
+
documentDid,
+
colorScheme,
+
isFirst,
+
}) => {
+
const block = wrapper.block;
+
if (!block || !("$type" in block) || !block.$type) {
+
return null;
+
}
+
const alignment = alignmentValue(wrapper.alignment);
+
switch (block.$type) {
+
case "pub.leaflet.blocks.header":
+
return (
+
<LeafletHeaderBlockView
+
block={block}
+
alignment={alignment}
+
colorScheme={colorScheme}
+
isFirst={isFirst}
+
/>
+
);
+
case "pub.leaflet.blocks.blockquote":
+
return (
+
<LeafletBlockquoteBlockView
+
block={block}
+
alignment={alignment}
+
colorScheme={colorScheme}
+
isFirst={isFirst}
+
/>
+
);
+
case "pub.leaflet.blocks.image":
+
return (
+
<LeafletImageBlockView
+
block={block}
+
alignment={alignment}
+
documentDid={documentDid}
+
colorScheme={colorScheme}
+
/>
+
);
+
case "pub.leaflet.blocks.unorderedList":
+
return (
+
<LeafletListBlockView
+
block={block}
+
alignment={alignment}
+
documentDid={documentDid}
+
colorScheme={colorScheme}
+
/>
+
);
+
case "pub.leaflet.blocks.website":
+
return (
+
<LeafletWebsiteBlockView
+
block={block}
+
alignment={alignment}
+
documentDid={documentDid}
+
colorScheme={colorScheme}
+
/>
+
);
+
case "pub.leaflet.blocks.iframe":
+
return (
+
<LeafletIframeBlockView block={block} alignment={alignment} />
+
);
+
case "pub.leaflet.blocks.math":
+
return (
+
<LeafletMathBlockView
+
block={block}
+
alignment={alignment}
+
colorScheme={colorScheme}
+
/>
+
);
+
case "pub.leaflet.blocks.code":
+
return (
+
<LeafletCodeBlockView
+
block={block}
+
alignment={alignment}
+
colorScheme={colorScheme}
+
/>
+
);
+
case "pub.leaflet.blocks.horizontalRule":
+
return (
+
<LeafletHorizontalRuleBlockView
+
alignment={alignment}
+
colorScheme={colorScheme}
+
/>
+
);
+
case "pub.leaflet.blocks.bskyPost":
+
return (
+
<LeafletBskyPostBlockView
+
block={block}
+
colorScheme={colorScheme}
+
/>
+
);
+
case "pub.leaflet.blocks.text":
+
default:
+
return (
+
<LeafletTextBlockView
+
block={block as LeafletTextBlock}
+
alignment={alignment}
+
colorScheme={colorScheme}
+
isFirst={isFirst}
+
/>
+
);
+
}
};
+
const LeafletTextBlockView: React.FC<{
+
block: LeafletTextBlock;
+
alignment?: React.CSSProperties["textAlign"];
+
colorScheme: "light" | "dark";
+
isFirst?: boolean;
+
}> = ({ block, alignment, colorScheme, isFirst }) => {
+
const segments = useMemo(
+
() => createFacetedSegments(block.plaintext, block.facets),
+
[block.plaintext, block.facets],
+
);
+
const textContent = block.plaintext ?? "";
+
if (!textContent.trim() && segments.length === 0) {
+
return null;
+
}
+
const palette = colorScheme === "dark" ? theme.dark : theme.light;
+
const style: React.CSSProperties = {
+
...base.paragraph,
+
...palette.paragraph,
+
...(alignment ? { textAlign: alignment } : undefined),
+
...(isFirst ? { marginTop: 0 } : undefined),
+
};
+
return (
+
<p style={style}>
+
{segments.map((segment, idx) => (
+
<React.Fragment key={`text-${idx}`}>
+
{renderSegment(segment, colorScheme)}
+
</React.Fragment>
+
))}
+
</p>
+
);
};
+
const LeafletHeaderBlockView: React.FC<{
+
block: LeafletHeaderBlock;
+
alignment?: React.CSSProperties["textAlign"];
+
colorScheme: "light" | "dark";
+
isFirst?: boolean;
+
}> = ({ block, alignment, colorScheme, isFirst }) => {
+
const palette = colorScheme === "dark" ? theme.dark : theme.light;
+
const level =
+
block.level && block.level >= 1 && block.level <= 6 ? block.level : 2;
+
const segments = useMemo(
+
() => createFacetedSegments(block.plaintext, block.facets),
+
[block.plaintext, block.facets],
+
);
+
const normalizedLevel = Math.min(Math.max(level, 1), 6) as
+
| 1
+
| 2
+
| 3
+
| 4
+
| 5
+
| 6;
+
const headingTag = (["h1", "h2", "h3", "h4", "h5", "h6"] as const)[
+
normalizedLevel - 1
+
];
+
const headingStyles = palette.heading[normalizedLevel];
+
const style: React.CSSProperties = {
+
...base.heading,
+
...headingStyles,
+
...(alignment ? { textAlign: alignment } : undefined),
+
...(isFirst ? { marginTop: 0 } : undefined),
+
};
+
return React.createElement(
+
headingTag,
+
{ style },
+
segments.map((segment, idx) => (
+
<React.Fragment key={`header-${idx}`}>
+
{renderSegment(segment, colorScheme)}
+
</React.Fragment>
+
)),
+
);
};
+
const LeafletBlockquoteBlockView: React.FC<{
+
block: LeafletBlockquoteBlock;
+
alignment?: React.CSSProperties["textAlign"];
+
colorScheme: "light" | "dark";
+
isFirst?: boolean;
+
}> = ({ block, alignment, colorScheme, isFirst }) => {
+
const segments = useMemo(
+
() => createFacetedSegments(block.plaintext, block.facets),
+
[block.plaintext, block.facets],
+
);
+
const textContent = block.plaintext ?? "";
+
if (!textContent.trim() && segments.length === 0) {
+
return null;
+
}
+
const palette = colorScheme === "dark" ? theme.dark : theme.light;
+
return (
+
<blockquote
+
style={{
+
...base.blockquote,
+
...palette.blockquote,
+
...(alignment ? { textAlign: alignment } : undefined),
+
...(isFirst ? { marginTop: 0 } : undefined),
+
}}
+
>
+
{segments.map((segment, idx) => (
+
<React.Fragment key={`quote-${idx}`}>
+
{renderSegment(segment, colorScheme)}
+
</React.Fragment>
+
))}
+
</blockquote>
+
);
};
+
const LeafletImageBlockView: React.FC<{
+
block: LeafletImageBlock;
+
alignment?: React.CSSProperties["textAlign"];
+
documentDid: string;
+
colorScheme: "light" | "dark";
+
}> = ({ block, alignment, documentDid, colorScheme }) => {
+
const palette = colorScheme === "dark" ? theme.dark : theme.light;
+
const cid = block.image?.ref?.$link ?? block.image?.cid;
+
const { url, loading, error } = useBlob(documentDid, cid);
+
const aspectRatio =
+
block.aspectRatio?.height && block.aspectRatio?.width
+
? `${block.aspectRatio.width} / ${block.aspectRatio.height}`
+
: undefined;
+
return (
+
<figure
+
style={{
+
...base.figure,
+
...palette.figure,
+
...(alignment ? { textAlign: alignment } : undefined),
+
}}
+
>
+
<div
+
style={{
+
...base.imageWrapper,
+
...palette.imageWrapper,
+
...(aspectRatio ? { aspectRatio } : {}),
+
}}
+
>
+
{url && !error ? (
+
<img
+
src={url}
+
alt={block.alt ?? ""}
+
style={{ ...base.image, ...palette.image }}
+
/>
+
) : (
+
<div
+
style={{
+
...base.imagePlaceholder,
+
...palette.imagePlaceholder,
+
}}
+
>
+
{loading
+
? "Loading image…"
+
: error
+
? "Image unavailable"
+
: "No image"}
+
</div>
+
)}
+
</div>
+
{block.alt && block.alt.trim().length > 0 && (
+
<figcaption style={{ ...base.caption, ...palette.caption }}>
+
{block.alt}
+
</figcaption>
+
)}
+
</figure>
+
);
};
+
const LeafletListBlockView: React.FC<{
+
block: LeafletUnorderedListBlock;
+
alignment?: React.CSSProperties["textAlign"];
+
documentDid: string;
+
colorScheme: "light" | "dark";
+
}> = ({ block, alignment, documentDid, colorScheme }) => {
+
const palette = colorScheme === "dark" ? theme.dark : theme.light;
+
return (
+
<ul
+
style={{
+
...base.list,
+
...palette.list,
+
...(alignment ? { textAlign: alignment } : undefined),
+
}}
+
>
+
{block.children?.map((child, idx) => (
+
<LeafletListItemRenderer
+
key={`list-item-${idx}`}
+
item={child}
+
documentDid={documentDid}
+
colorScheme={colorScheme}
+
alignment={alignment}
+
/>
+
))}
+
</ul>
+
);
};
+
const LeafletListItemRenderer: React.FC<{
+
item: LeafletListItem;
+
documentDid: string;
+
colorScheme: "light" | "dark";
+
alignment?: React.CSSProperties["textAlign"];
+
}> = ({ item, documentDid, colorScheme, alignment }) => {
+
return (
+
<li
+
style={{
+
...base.listItem,
+
...(alignment ? { textAlign: alignment } : undefined),
+
}}
+
>
+
<div>
+
<LeafletInlineBlock
+
block={item.content}
+
colorScheme={colorScheme}
+
documentDid={documentDid}
+
alignment={alignment}
+
/>
+
</div>
+
{item.children && item.children.length > 0 && (
+
<ul
+
style={{
+
...base.nestedList,
+
...(alignment ? { textAlign: alignment } : undefined),
+
}}
+
>
+
{item.children.map((child, idx) => (
+
<LeafletListItemRenderer
+
key={`nested-${idx}`}
+
item={child}
+
documentDid={documentDid}
+
colorScheme={colorScheme}
+
alignment={alignment}
+
/>
+
))}
+
</ul>
+
)}
+
</li>
+
);
};
+
const LeafletInlineBlock: React.FC<{
+
block: LeafletBlock;
+
colorScheme: "light" | "dark";
+
documentDid: string;
+
alignment?: React.CSSProperties["textAlign"];
+
}> = ({ block, colorScheme, documentDid, alignment }) => {
+
switch (block.$type) {
+
case "pub.leaflet.blocks.header":
+
return (
+
<LeafletHeaderBlockView
+
block={block as LeafletHeaderBlock}
+
colorScheme={colorScheme}
+
alignment={alignment}
+
/>
+
);
+
case "pub.leaflet.blocks.blockquote":
+
return (
+
<LeafletBlockquoteBlockView
+
block={block as LeafletBlockquoteBlock}
+
colorScheme={colorScheme}
+
alignment={alignment}
+
/>
+
);
+
case "pub.leaflet.blocks.image":
+
return (
+
<LeafletImageBlockView
+
block={block as LeafletImageBlock}
+
documentDid={documentDid}
+
colorScheme={colorScheme}
+
alignment={alignment}
+
/>
+
);
+
default:
+
return (
+
<LeafletTextBlockView
+
block={block as LeafletTextBlock}
+
colorScheme={colorScheme}
+
alignment={alignment}
+
/>
+
);
+
}
};
+
const LeafletWebsiteBlockView: React.FC<{
+
block: LeafletWebsiteBlock;
+
alignment?: React.CSSProperties["textAlign"];
+
documentDid: string;
+
colorScheme: "light" | "dark";
+
}> = ({ block, alignment, documentDid, colorScheme }) => {
+
const palette = colorScheme === "dark" ? theme.dark : theme.light;
+
const previewCid =
+
block.previewImage?.ref?.$link ?? block.previewImage?.cid;
+
const { url, loading, error } = useBlob(documentDid, previewCid);
+
return (
+
<a
+
href={block.src}
+
target="_blank"
+
rel="noopener noreferrer"
+
style={{
+
...base.linkCard,
+
...palette.linkCard,
+
...(alignment ? { textAlign: alignment } : undefined),
+
}}
+
>
+
{url && !error ? (
+
<img
+
src={url}
+
alt={block.title ?? "Website preview"}
+
style={{ ...base.linkPreview, ...palette.linkPreview }}
+
/>
+
) : (
+
<div
+
style={{
+
...base.linkPreviewPlaceholder,
+
...palette.linkPreviewPlaceholder,
+
}}
+
>
+
{loading ? "Loading preview…" : "Open link"}
+
</div>
+
)}
+
<div style={base.linkContent}>
+
{block.title && (
+
<strong style={palette.linkTitle}>{block.title}</strong>
+
)}
+
{block.description && (
+
<p style={palette.linkDescription}>{block.description}</p>
+
)}
+
<span style={palette.linkUrl}>{block.src}</span>
+
</div>
+
</a>
+
);
};
+
const LeafletIframeBlockView: React.FC<{
+
block: LeafletIFrameBlock;
+
alignment?: React.CSSProperties["textAlign"];
+
}> = ({ block, alignment }) => {
+
return (
+
<div style={{ ...(alignment ? { textAlign: alignment } : undefined) }}>
+
<iframe
+
src={block.url}
+
title={block.url}
+
style={{
+
...base.iframe,
+
...(block.height
+
? { height: Math.min(Math.max(block.height, 120), 800) }
+
: {}),
+
}}
+
loading="lazy"
+
allowFullScreen
+
/>
+
</div>
+
);
};
+
const LeafletMathBlockView: React.FC<{
+
block: LeafletMathBlock;
+
alignment?: React.CSSProperties["textAlign"];
+
colorScheme: "light" | "dark";
+
}> = ({ block, alignment, colorScheme }) => {
+
const palette = colorScheme === "dark" ? theme.dark : theme.light;
+
return (
+
<pre
+
style={{
+
...base.math,
+
...palette.math,
+
...(alignment ? { textAlign: alignment } : undefined),
+
}}
+
>
+
{block.tex}
+
</pre>
+
);
};
+
const LeafletCodeBlockView: React.FC<{
+
block: LeafletCodeBlock;
+
alignment?: React.CSSProperties["textAlign"];
+
colorScheme: "light" | "dark";
+
}> = ({ block, alignment, colorScheme }) => {
+
const palette = colorScheme === "dark" ? theme.dark : theme.light;
+
const codeRef = useRef<HTMLElement | null>(null);
+
const langClass = block.language
+
? `language-${block.language.toLowerCase()}`
+
: undefined;
+
return (
+
<pre
+
style={{
+
...base.code,
+
...palette.code,
+
...(alignment ? { textAlign: alignment } : undefined),
+
}}
+
>
+
<code ref={codeRef} className={langClass}>
+
{block.plaintext}
+
</code>
+
</pre>
+
);
};
+
const LeafletHorizontalRuleBlockView: React.FC<{
+
alignment?: React.CSSProperties["textAlign"];
+
colorScheme: "light" | "dark";
+
}> = ({ alignment, colorScheme }) => {
+
const palette = colorScheme === "dark" ? theme.dark : theme.light;
+
return (
+
<hr
+
style={{
+
...base.hr,
+
...palette.hr,
+
marginLeft: alignment ? "auto" : undefined,
+
marginRight: alignment ? "auto" : undefined,
+
}}
+
/>
+
);
};
+
const LeafletBskyPostBlockView: React.FC<{
+
block: LeafletBskyPostBlock;
+
colorScheme: "light" | "dark";
+
}> = ({ block, colorScheme }) => {
+
const parsed = parseAtUri(block.postRef?.uri);
+
if (!parsed) {
+
return (
+
<div style={base.embedFallback}>Referenced post unavailable.</div>
+
);
+
}
+
return (
+
<BlueskyPost
+
did={parsed.did}
+
rkey={parsed.rkey}
+
colorScheme={colorScheme}
+
iconPlacement="linkInline"
+
/>
+
);
};
+
function alignmentValue(
+
value?: LeafletAlignmentValue,
+
): React.CSSProperties["textAlign"] | undefined {
+
if (!value) return undefined;
+
let normalized = value.startsWith("#") ? value.slice(1) : value;
+
if (normalized.includes("#")) {
+
normalized = normalized.split("#").pop() ?? normalized;
+
}
+
if (normalized.startsWith("lex:")) {
+
normalized = normalized.split(":").pop() ?? normalized;
+
}
+
switch (normalized) {
+
case "textAlignLeft":
+
return "left";
+
case "textAlignCenter":
+
return "center";
+
case "textAlignRight":
+
return "right";
+
case "textAlignJustify":
+
return "justify";
+
default:
+
return undefined;
+
}
}
+
function useAuthorLabel(
+
author: string | undefined,
+
authorDid: string | undefined,
+
): string | undefined {
+
const { handle } = useDidResolution(authorDid);
+
if (!author) return undefined;
+
if (handle) return `@${handle}`;
+
if (authorDid) return formatDidForLabel(authorDid);
+
return author;
}
interface Segment {
+
text: string;
+
features: LeafletRichTextFeature[];
}
+
function createFacetedSegments(
+
plaintext: string,
+
facets?: LeafletRichTextFacet[],
+
): Segment[] {
+
if (!facets?.length) {
+
return [{ text: plaintext, features: [] }];
+
}
+
const prefix = buildBytePrefix(plaintext);
+
const startEvents = new Map<number, LeafletRichTextFeature[]>();
+
const endEvents = new Map<number, LeafletRichTextFeature[]>();
+
const boundaries = new Set<number>([0, prefix.length - 1]);
+
for (const facet of facets) {
+
const { byteStart, byteEnd } = facet.index ?? {};
+
if (
+
typeof byteStart !== "number" ||
+
typeof byteEnd !== "number" ||
+
byteStart >= byteEnd
+
)
+
continue;
+
const start = byteOffsetToCharIndex(prefix, byteStart);
+
const end = byteOffsetToCharIndex(prefix, byteEnd);
+
if (start >= end) continue;
+
boundaries.add(start);
+
boundaries.add(end);
+
if (facet.features?.length) {
+
startEvents.set(start, [
+
...(startEvents.get(start) ?? []),
+
...facet.features,
+
]);
+
endEvents.set(end, [
+
...(endEvents.get(end) ?? []),
+
...facet.features,
+
]);
+
}
+
}
+
const sortedBounds = [...boundaries].sort((a, b) => a - b);
+
const segments: Segment[] = [];
+
let active: LeafletRichTextFeature[] = [];
+
for (let i = 0; i < sortedBounds.length - 1; i++) {
+
const boundary = sortedBounds[i];
+
const next = sortedBounds[i + 1];
+
const endFeatures = endEvents.get(boundary);
+
if (endFeatures?.length) {
+
active = active.filter((feature) => !endFeatures.includes(feature));
+
}
+
const startFeatures = startEvents.get(boundary);
+
if (startFeatures?.length) {
+
active = [...active, ...startFeatures];
+
}
+
if (boundary === next) continue;
+
const text = sliceByCharRange(plaintext, boundary, next);
+
segments.push({ text, features: active.slice() });
+
}
+
return segments;
}
function buildBytePrefix(text: string): number[] {
+
const encoder = new TextEncoder();
+
const prefix: number[] = [0];
+
let byteCount = 0;
+
for (let i = 0; i < text.length; ) {
+
const codePoint = text.codePointAt(i)!;
+
const char = String.fromCodePoint(codePoint);
+
const encoded = encoder.encode(char);
+
byteCount += encoded.length;
+
prefix.push(byteCount);
+
i += codePoint > 0xffff ? 2 : 1;
+
}
+
return prefix;
}
function byteOffsetToCharIndex(prefix: number[], byteOffset: number): number {
+
for (let i = 0; i < prefix.length; i++) {
+
if (prefix[i] === byteOffset) return i;
+
if (prefix[i] > byteOffset) return Math.max(0, i - 1);
+
}
+
return prefix.length - 1;
}
function sliceByCharRange(text: string, start: number, end: number): string {
+
if (start <= 0 && end >= text.length) return text;
+
let result = "";
+
let charIndex = 0;
+
for (let i = 0; i < text.length && charIndex < end; ) {
+
const codePoint = text.codePointAt(i)!;
+
const char = String.fromCodePoint(codePoint);
+
if (charIndex >= start && charIndex < end) result += char;
+
i += codePoint > 0xffff ? 2 : 1;
+
charIndex++;
+
}
+
return result;
}
+
function renderSegment(
+
segment: Segment,
+
colorScheme: "light" | "dark",
+
): React.ReactNode {
+
const parts = segment.text.split("\n");
+
return parts.flatMap((part, idx) => {
+
const key = `${segment.text}-${idx}-${part.length}`;
+
const wrapped = applyFeatures(
+
part.length ? part : "\u00a0",
+
segment.features,
+
key,
+
colorScheme,
+
);
+
if (idx === parts.length - 1) return wrapped;
+
return [wrapped, <br key={`${key}-br`} />];
+
});
}
+
function applyFeatures(
+
content: React.ReactNode,
+
features: LeafletRichTextFeature[],
+
key: string,
+
colorScheme: "light" | "dark",
+
): React.ReactNode {
+
if (!features?.length)
+
return <React.Fragment key={key}>{content}</React.Fragment>;
+
return (
+
<React.Fragment key={key}>
+
{features.reduce<React.ReactNode>(
+
(child, feature, idx) =>
+
wrapFeature(
+
child,
+
feature,
+
`${key}-feature-${idx}`,
+
colorScheme,
+
),
+
content,
+
)}
+
</React.Fragment>
+
);
}
+
function wrapFeature(
+
child: React.ReactNode,
+
feature: LeafletRichTextFeature,
+
key: string,
+
colorScheme: "light" | "dark",
+
): React.ReactNode {
+
switch (feature.$type) {
+
case "pub.leaflet.richtext.facet#link":
+
return (
+
<a
+
key={key}
+
href={feature.uri}
+
target="_blank"
+
rel="noopener noreferrer"
+
style={linkStyles[colorScheme]}
+
>
+
{child}
+
</a>
+
);
+
case "pub.leaflet.richtext.facet#code":
+
return (
+
<code key={key} style={inlineCodeStyles[colorScheme]}>
+
{child}
+
</code>
+
);
+
case "pub.leaflet.richtext.facet#highlight":
+
return (
+
<mark key={key} style={highlightStyles[colorScheme]}>
+
{child}
+
</mark>
+
);
+
case "pub.leaflet.richtext.facet#underline":
+
return (
+
<span key={key} style={{ textDecoration: "underline" }}>
+
{child}
+
</span>
+
);
+
case "pub.leaflet.richtext.facet#strikethrough":
+
return (
+
<span key={key} style={{ textDecoration: "line-through" }}>
+
{child}
+
</span>
+
);
+
case "pub.leaflet.richtext.facet#bold":
+
return <strong key={key}>{child}</strong>;
+
case "pub.leaflet.richtext.facet#italic":
+
return <em key={key}>{child}</em>;
+
case "pub.leaflet.richtext.facet#id":
+
return (
+
<span key={key} id={feature.id}>
+
{child}
+
</span>
+
);
+
default:
+
return <span key={key}>{child}</span>;
+
}
}
const base: Record<string, React.CSSProperties> = {
+
container: {
+
display: "flex",
+
flexDirection: "column",
+
gap: 24,
+
padding: "24px 28px",
+
borderRadius: 20,
+
border: "1px solid transparent",
+
maxWidth: 720,
+
width: "100%",
+
fontFamily:
+
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
+
},
+
header: {
+
display: "flex",
+
flexDirection: "column",
+
gap: 16,
+
},
+
headerContent: {
+
display: "flex",
+
flexDirection: "column",
+
gap: 8,
+
},
+
title: {
+
fontSize: 32,
+
margin: 0,
+
lineHeight: 1.15,
+
},
+
subtitle: {
+
margin: 0,
+
fontSize: 16,
+
lineHeight: 1.5,
+
},
+
meta: {
+
display: "flex",
+
flexWrap: "wrap",
+
gap: 8,
+
alignItems: "center",
+
fontSize: 14,
+
},
+
body: {
+
display: "flex",
+
flexDirection: "column",
+
gap: 18,
+
},
+
page: {
+
display: "flex",
+
flexDirection: "column",
+
gap: 18,
+
},
+
paragraph: {
+
margin: "1em 0 0",
+
lineHeight: 1.65,
+
fontSize: 16,
+
},
+
heading: {
+
margin: "0.5em 0 0",
+
fontWeight: 700,
+
},
+
blockquote: {
+
margin: "1em 0 0",
+
padding: "0.6em 1em",
+
borderLeft: "4px solid",
+
},
+
figure: {
+
margin: "1.2em 0 0",
+
display: "flex",
+
flexDirection: "column",
+
gap: 12,
+
},
+
imageWrapper: {
+
borderRadius: 16,
+
overflow: "hidden",
+
width: "100%",
+
position: "relative",
+
background: "#e2e8f0",
+
},
+
image: {
+
width: "100%",
+
height: "100%",
+
objectFit: "cover",
+
display: "block",
+
},
+
imagePlaceholder: {
+
width: "100%",
+
padding: "24px 16px",
+
textAlign: "center",
+
},
+
caption: {
+
fontSize: 13,
+
lineHeight: 1.4,
+
},
+
list: {
+
paddingLeft: 28,
+
margin: "1em 0 0",
+
listStyleType: "disc",
+
listStylePosition: "outside",
+
},
+
nestedList: {
+
paddingLeft: 20,
+
marginTop: 8,
+
listStyleType: "circle",
+
listStylePosition: "outside",
+
},
+
listItem: {
+
marginTop: 8,
+
display: "list-item",
+
},
+
linkCard: {
+
borderRadius: 16,
+
border: "1px solid",
+
display: "flex",
+
flexDirection: "column",
+
overflow: "hidden",
+
textDecoration: "none",
+
},
+
linkPreview: {
+
width: "100%",
+
height: 180,
+
objectFit: "cover",
+
},
+
linkPreviewPlaceholder: {
+
width: "100%",
+
height: 180,
+
display: "flex",
+
alignItems: "center",
+
justifyContent: "center",
+
fontSize: 14,
+
},
+
linkContent: {
+
display: "flex",
+
flexDirection: "column",
+
gap: 6,
+
padding: "16px 18px",
+
},
+
iframe: {
+
width: "100%",
+
height: 360,
+
border: "1px solid #cbd5f5",
+
borderRadius: 16,
+
},
+
math: {
+
margin: "1em 0 0",
+
padding: "14px 16px",
+
borderRadius: 12,
+
fontFamily: 'Menlo, Consolas, "SFMono-Regular", ui-monospace',
+
overflowX: "auto",
+
},
+
code: {
+
margin: "1em 0 0",
+
padding: "14px 16px",
+
borderRadius: 12,
+
overflowX: "auto",
+
fontSize: 14,
+
},
+
hr: {
+
border: 0,
+
borderTop: "1px solid",
+
margin: "24px 0 0",
+
},
+
embedFallback: {
+
padding: "12px 16px",
+
borderRadius: 12,
+
border: "1px solid #e2e8f0",
+
fontSize: 14,
+
},
};
const theme = {
+
light: {
+
container: {
+
background: "#ffffff",
+
borderColor: "#e2e8f0",
+
color: "#0f172a",
+
boxShadow: "0 4px 18px rgba(15, 23, 42, 0.06)",
+
},
+
header: {},
+
title: {
+
color: "#0f172a",
+
},
+
subtitle: {
+
color: "#475569",
+
},
+
meta: {
+
color: "#64748b",
+
},
+
metaLink: {
+
color: "#2563eb",
+
textDecoration: "none",
+
} satisfies React.CSSProperties,
+
metaSeparator: {
+
margin: "0 4px",
+
} satisfies React.CSSProperties,
+
paragraph: {
+
color: "#1f2937",
+
},
+
heading: {
+
1: { color: "#0f172a", fontSize: 30 },
+
2: { color: "#0f172a", fontSize: 28 },
+
3: { color: "#0f172a", fontSize: 24 },
+
4: { color: "#0f172a", fontSize: 20 },
+
5: { color: "#0f172a", fontSize: 18 },
+
6: { color: "#0f172a", fontSize: 16 },
+
} satisfies Record<number, React.CSSProperties>,
+
blockquote: {
+
background: "#f8fafc",
+
borderColor: "#cbd5f5",
+
color: "#1f2937",
+
},
+
figure: {},
+
imageWrapper: {
+
background: "#e2e8f0",
+
},
+
image: {},
+
imagePlaceholder: {
+
color: "#475569",
+
},
+
caption: {
+
color: "#475569",
+
},
+
list: {
+
color: "#1f2937",
+
},
+
linkCard: {
+
borderColor: "#e2e8f0",
+
background: "#f8fafc",
+
color: "#0f172a",
+
},
+
linkPreview: {},
+
linkPreviewPlaceholder: {
+
background: "#e2e8f0",
+
color: "#475569",
+
},
+
linkTitle: {
+
fontSize: 16,
+
color: "#0f172a",
+
} satisfies React.CSSProperties,
+
linkDescription: {
+
margin: 0,
+
fontSize: 14,
+
color: "#475569",
+
lineHeight: 1.5,
+
} satisfies React.CSSProperties,
+
linkUrl: {
+
fontSize: 13,
+
color: "#2563eb",
+
wordBreak: "break-all",
+
} satisfies React.CSSProperties,
+
math: {
+
background: "#f1f5f9",
+
color: "#1f2937",
+
border: "1px solid #e2e8f0",
+
},
+
code: {
+
background: "#0f172a",
+
color: "#e2e8f0",
+
},
+
hr: {
+
borderColor: "#e2e8f0",
+
},
+
},
+
dark: {
+
container: {
+
background: "rgba(15, 23, 42, 0.6)",
+
borderColor: "rgba(148, 163, 184, 0.3)",
+
color: "#e2e8f0",
+
backdropFilter: "blur(8px)",
+
boxShadow: "0 10px 40px rgba(2, 6, 23, 0.45)",
+
},
+
header: {},
+
title: {
+
color: "#f8fafc",
+
},
+
subtitle: {
+
color: "#cbd5f5",
+
},
+
meta: {
+
color: "#94a3b8",
+
},
+
metaLink: {
+
color: "#38bdf8",
+
textDecoration: "none",
+
} satisfies React.CSSProperties,
+
metaSeparator: {
+
margin: "0 4px",
+
} satisfies React.CSSProperties,
+
paragraph: {
+
color: "#e2e8f0",
+
},
+
heading: {
+
1: { color: "#f8fafc", fontSize: 30 },
+
2: { color: "#f8fafc", fontSize: 28 },
+
3: { color: "#f8fafc", fontSize: 24 },
+
4: { color: "#e2e8f0", fontSize: 20 },
+
5: { color: "#e2e8f0", fontSize: 18 },
+
6: { color: "#e2e8f0", fontSize: 16 },
+
} satisfies Record<number, React.CSSProperties>,
+
blockquote: {
+
background: "rgba(30, 41, 59, 0.6)",
+
borderColor: "#38bdf8",
+
color: "#e2e8f0",
+
},
+
figure: {},
+
imageWrapper: {
+
background: "#1e293b",
+
},
+
image: {},
+
imagePlaceholder: {
+
color: "#94a3b8",
+
},
+
caption: {
+
color: "#94a3b8",
+
},
+
list: {
+
color: "#f1f5f9",
+
},
+
linkCard: {
+
borderColor: "rgba(148, 163, 184, 0.3)",
+
background: "rgba(15, 23, 42, 0.8)",
+
color: "#e2e8f0",
+
},
+
linkPreview: {},
+
linkPreviewPlaceholder: {
+
background: "#1e293b",
+
color: "#94a3b8",
+
},
+
linkTitle: {
+
fontSize: 16,
+
color: "#e0f2fe",
+
} satisfies React.CSSProperties,
+
linkDescription: {
+
margin: 0,
+
fontSize: 14,
+
color: "#cbd5f5",
+
lineHeight: 1.5,
+
} satisfies React.CSSProperties,
+
linkUrl: {
+
fontSize: 13,
+
color: "#38bdf8",
+
wordBreak: "break-all",
+
} satisfies React.CSSProperties,
+
math: {
+
background: "rgba(15, 23, 42, 0.8)",
+
color: "#e2e8f0",
+
border: "1px solid rgba(148, 163, 184, 0.35)",
+
},
+
code: {
+
background: "#020617",
+
color: "#e2e8f0",
+
},
+
hr: {
+
borderColor: "rgba(148, 163, 184, 0.3)",
+
},
+
},
} as const;
const linkStyles = {
+
light: {
+
color: "#2563eb",
+
textDecoration: "underline",
+
} satisfies React.CSSProperties,
+
dark: {
+
color: "#38bdf8",
+
textDecoration: "underline",
+
} satisfies React.CSSProperties,
} as const;
const inlineCodeStyles = {
+
light: {
+
fontFamily: 'Menlo, Consolas, "SFMono-Regular", ui-monospace',
+
background: "#f1f5f9",
+
padding: "0 4px",
+
borderRadius: 4,
+
} satisfies React.CSSProperties,
+
dark: {
+
fontFamily: 'Menlo, Consolas, "SFMono-Regular", ui-monospace',
+
background: "#1e293b",
+
padding: "0 4px",
+
borderRadius: 4,
+
} satisfies React.CSSProperties,
} as const;
const highlightStyles = {
+
light: {
+
background: "#fef08a",
+
} satisfies React.CSSProperties,
+
dark: {
+
background: "#facc15",
+
color: "#0f172a",
+
} satisfies React.CSSProperties,
} as const;
export default LeafletDocumentRenderer;
+110 -72
lib/renderers/TangledStringRenderer.tsx
···
-
import React from 'react';
-
import type { ShTangledString } from '@atcute/tangled';
-
import { useColorScheme, type ColorSchemePreference } from '../hooks/useColorScheme';
export type TangledStringRecord = ShTangledString.Main;
···
canonicalUrl?: string;
}
-
export const TangledStringRenderer: React.FC<TangledStringRendererProps> = ({ record, error, loading, colorScheme = 'system', did, rkey, canonicalUrl }) => {
const scheme = useColorScheme(colorScheme);
-
if (error) return <div style={{ padding: 8, color: 'crimson' }}>Failed to load snippet.</div>;
if (loading && !record) return <div style={{ padding: 8 }}>Loading…</div>;
-
const palette = scheme === 'dark' ? theme.dark : theme.light;
-
const viewUrl = canonicalUrl ?? `https://tangled.org/strings/${did}/${encodeURIComponent(rkey)}`;
-
const timestamp = new Date(record.createdAt).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
return (
<div style={{ ...base.container, ...palette.container }}>
<div style={{ ...base.header, ...palette.header }}>
-
<strong style={{ ...base.filename, ...palette.filename }}>{record.filename}</strong>
<div style={{ ...base.headerRight, ...palette.headerRight }}>
-
<time style={{ ...base.timestamp, ...palette.timestamp }} dateTime={record.createdAt}>{timestamp}</time>
-
<a href={viewUrl} target="_blank" rel="noopener noreferrer" style={{ ...base.headerLink, ...palette.headerLink }}>
View on Tangled
</a>
</div>
</div>
{record.description && (
-
<div style={{ ...base.description, ...palette.description }}>{record.description}</div>
)}
<pre style={{ ...base.codeBlock, ...palette.codeBlock }}>
<code>{record.contents}</code>
···
const base: Record<string, React.CSSProperties> = {
container: {
-
fontFamily: 'system-ui, sans-serif',
borderRadius: 6,
-
overflow: 'hidden',
-
transition: 'background-color 180ms ease, border-color 180ms ease, color 180ms ease, box-shadow 180ms ease',
-
width: '100%'
},
header: {
-
padding: '10px 16px',
-
display: 'flex',
-
justifyContent: 'space-between',
-
alignItems: 'center',
-
gap: 12
},
headerRight: {
-
display: 'flex',
-
alignItems: 'center',
gap: 12,
-
flexWrap: 'wrap',
-
justifyContent: 'flex-end'
},
filename: {
-
fontFamily: 'SFMono-Regular, ui-monospace, Menlo, Monaco, "Courier New", monospace',
fontSize: 13,
-
wordBreak: 'break-all'
},
timestamp: {
-
fontSize: 12
},
headerLink: {
fontSize: 12,
fontWeight: 600,
-
textDecoration: 'none'
},
description: {
-
padding: '10px 16px',
fontSize: 13,
-
borderTop: '1px solid transparent'
},
codeBlock: {
margin: 0,
-
padding: '16px',
fontSize: 13,
-
overflowX: 'auto',
-
borderTop: '1px solid transparent',
-
fontFamily: 'SFMono-Regular, ui-monospace, Menlo, Monaco, "Courier New", monospace'
-
}
};
const theme = {
light: {
container: {
-
border: '1px solid #d0d7de',
-
background: '#f6f8fa',
-
color: '#1f2328',
-
boxShadow: '0 1px 2px rgba(31,35,40,0.05)'
},
header: {
-
background: '#f6f8fa',
-
borderBottom: '1px solid #d0d7de'
},
headerRight: {},
filename: {
-
color: '#1f2328'
},
timestamp: {
-
color: '#57606a'
},
headerLink: {
-
color: '#2563eb'
},
description: {
-
background: '#ffffff',
-
borderBottom: '1px solid #d0d7de',
-
borderTopColor: '#d0d7de',
-
color: '#1f2328'
},
codeBlock: {
-
background: '#ffffff',
-
color: '#1f2328',
-
borderTopColor: '#d0d7de'
-
}
},
dark: {
container: {
-
border: '1px solid #30363d',
-
background: '#0d1117',
-
color: '#c9d1d9',
-
boxShadow: '0 0 0 1px rgba(1,4,9,0.3) inset'
},
header: {
-
background: '#161b22',
-
borderBottom: '1px solid #30363d'
},
headerRight: {},
filename: {
-
color: '#c9d1d9'
},
timestamp: {
-
color: '#8b949e'
},
headerLink: {
-
color: '#58a6ff'
},
description: {
-
background: '#161b22',
-
borderBottom: '1px solid #30363d',
-
borderTopColor: '#30363d',
-
color: '#c9d1d9'
},
codeBlock: {
-
background: '#0d1117',
-
color: '#c9d1d9',
-
borderTopColor: '#30363d'
-
}
-
}
-
} satisfies Record<'light' | 'dark', Record<string, React.CSSProperties>>;
export default TangledStringRenderer;
···
+
import React from "react";
+
import type { ShTangledString } from "@atcute/tangled";
+
import {
+
useColorScheme,
+
type ColorSchemePreference,
+
} from "../hooks/useColorScheme";
export type TangledStringRecord = ShTangledString.Main;
···
canonicalUrl?: string;
}
+
export const TangledStringRenderer: React.FC<TangledStringRendererProps> = ({
+
record,
+
error,
+
loading,
+
colorScheme = "system",
+
did,
+
rkey,
+
canonicalUrl,
+
}) => {
const scheme = useColorScheme(colorScheme);
+
if (error)
+
return (
+
<div style={{ padding: 8, color: "crimson" }}>
+
Failed to load snippet.
+
</div>
+
);
if (loading && !record) return <div style={{ padding: 8 }}>Loading…</div>;
+
const palette = scheme === "dark" ? theme.dark : theme.light;
+
const viewUrl =
+
canonicalUrl ??
+
`https://tangled.org/strings/${did}/${encodeURIComponent(rkey)}`;
+
const timestamp = new Date(record.createdAt).toLocaleString(undefined, {
+
dateStyle: "medium",
+
timeStyle: "short",
+
});
return (
<div style={{ ...base.container, ...palette.container }}>
<div style={{ ...base.header, ...palette.header }}>
+
<strong style={{ ...base.filename, ...palette.filename }}>
+
{record.filename}
+
</strong>
<div style={{ ...base.headerRight, ...palette.headerRight }}>
+
<time
+
style={{ ...base.timestamp, ...palette.timestamp }}
+
dateTime={record.createdAt}
+
>
+
{timestamp}
+
</time>
+
<a
+
href={viewUrl}
+
target="_blank"
+
rel="noopener noreferrer"
+
style={{ ...base.headerLink, ...palette.headerLink }}
+
>
View on Tangled
</a>
</div>
</div>
{record.description && (
+
<div style={{ ...base.description, ...palette.description }}>
+
{record.description}
+
</div>
)}
<pre style={{ ...base.codeBlock, ...palette.codeBlock }}>
<code>{record.contents}</code>
···
const base: Record<string, React.CSSProperties> = {
container: {
+
fontFamily: "system-ui, sans-serif",
borderRadius: 6,
+
overflow: "hidden",
+
transition:
+
"background-color 180ms ease, border-color 180ms ease, color 180ms ease, box-shadow 180ms ease",
+
width: "100%",
},
header: {
+
padding: "10px 16px",
+
display: "flex",
+
justifyContent: "space-between",
+
alignItems: "center",
+
gap: 12,
},
headerRight: {
+
display: "flex",
+
alignItems: "center",
gap: 12,
+
flexWrap: "wrap",
+
justifyContent: "flex-end",
},
filename: {
+
fontFamily:
+
'SFMono-Regular, ui-monospace, Menlo, Monaco, "Courier New", monospace',
fontSize: 13,
+
wordBreak: "break-all",
},
timestamp: {
+
fontSize: 12,
},
headerLink: {
fontSize: 12,
fontWeight: 600,
+
textDecoration: "none",
},
description: {
+
padding: "10px 16px",
fontSize: 13,
+
borderTop: "1px solid transparent",
},
codeBlock: {
margin: 0,
+
padding: "16px",
fontSize: 13,
+
overflowX: "auto",
+
borderTop: "1px solid transparent",
+
fontFamily:
+
'SFMono-Regular, ui-monospace, Menlo, Monaco, "Courier New", monospace',
+
},
};
const theme = {
light: {
container: {
+
border: "1px solid #d0d7de",
+
background: "#f6f8fa",
+
color: "#1f2328",
+
boxShadow: "0 1px 2px rgba(31,35,40,0.05)",
},
header: {
+
background: "#f6f8fa",
+
borderBottom: "1px solid #d0d7de",
},
headerRight: {},
filename: {
+
color: "#1f2328",
},
timestamp: {
+
color: "#57606a",
},
headerLink: {
+
color: "#2563eb",
},
description: {
+
background: "#ffffff",
+
borderBottom: "1px solid #d0d7de",
+
borderTopColor: "#d0d7de",
+
color: "#1f2328",
},
codeBlock: {
+
background: "#ffffff",
+
color: "#1f2328",
+
borderTopColor: "#d0d7de",
+
},
},
dark: {
container: {
+
border: "1px solid #30363d",
+
background: "#0d1117",
+
color: "#c9d1d9",
+
boxShadow: "0 0 0 1px rgba(1,4,9,0.3) inset",
},
header: {
+
background: "#161b22",
+
borderBottom: "1px solid #30363d",
},
headerRight: {},
filename: {
+
color: "#c9d1d9",
},
timestamp: {
+
color: "#8b949e",
},
headerLink: {
+
color: "#58a6ff",
},
description: {
+
background: "#161b22",
+
borderBottom: "1px solid #30363d",
+
borderTopColor: "#30363d",
+
color: "#c9d1d9",
},
codeBlock: {
+
background: "#0d1117",
+
color: "#c9d1d9",
+
borderTopColor: "#30363d",
+
},
+
},
+
} satisfies Record<"light" | "dark", Record<string, React.CSSProperties>>;
export default TangledStringRenderer;
+1 -1
lib/types/bluesky.ts
···
// Re-export precise lexicon types from @atcute/bluesky instead of redefining.
-
import type { AppBskyFeedPost, AppBskyActorProfile } from '@atcute/bluesky';
// The atcute lexicon modules expose Main interface for record input shapes.
export type FeedPostRecord = AppBskyFeedPost.Main;
···
// Re-export precise lexicon types from @atcute/bluesky instead of redefining.
+
import type { AppBskyFeedPost, AppBskyActorProfile } from "@atcute/bluesky";
// The atcute lexicon modules expose Main interface for record input shapes.
export type FeedPostRecord = AppBskyFeedPost.Main;
+133 -127
lib/types/leaflet.ts
···
export interface StrongRef {
-
uri: string;
-
cid: string;
}
export interface LeafletDocumentRecord {
-
$type?: "pub.leaflet.document";
-
title: string;
-
postRef?: StrongRef;
-
description?: string;
-
publishedAt?: string;
-
publication: string;
-
author: string;
-
pages: LeafletDocumentPage[];
}
export type LeafletDocumentPage = LeafletLinearDocumentPage;
export interface LeafletLinearDocumentPage {
-
$type?: "pub.leaflet.pages.linearDocument";
-
blocks?: LeafletLinearDocumentBlock[];
}
export type LeafletAlignmentValue =
-
| "#textAlignLeft"
-
| "#textAlignCenter"
-
| "#textAlignRight"
-
| "#textAlignJustify"
-
| "textAlignLeft"
-
| "textAlignCenter"
-
| "textAlignRight"
-
| "textAlignJustify";
export interface LeafletLinearDocumentBlock {
-
block: LeafletBlock;
-
alignment?: LeafletAlignmentValue;
}
export type LeafletBlock =
-
| LeafletTextBlock
-
| LeafletHeaderBlock
-
| LeafletBlockquoteBlock
-
| LeafletImageBlock
-
| LeafletUnorderedListBlock
-
| LeafletWebsiteBlock
-
| LeafletIFrameBlock
-
| LeafletMathBlock
-
| LeafletCodeBlock
-
| LeafletHorizontalRuleBlock
-
| LeafletBskyPostBlock;
export interface LeafletBaseTextBlock {
-
plaintext: string;
-
facets?: LeafletRichTextFacet[];
}
export interface LeafletTextBlock extends LeafletBaseTextBlock {
-
$type?: "pub.leaflet.blocks.text";
}
export interface LeafletHeaderBlock extends LeafletBaseTextBlock {
-
$type?: "pub.leaflet.blocks.header";
-
level?: number;
}
export interface LeafletBlockquoteBlock extends LeafletBaseTextBlock {
-
$type?: "pub.leaflet.blocks.blockquote";
}
export interface LeafletImageBlock {
-
$type?: "pub.leaflet.blocks.image";
-
image: LeafletBlobRef;
-
alt?: string;
-
aspectRatio: {
-
width: number;
-
height: number;
-
};
}
export interface LeafletUnorderedListBlock {
-
$type?: "pub.leaflet.blocks.unorderedList";
-
children: LeafletListItem[];
}
export interface LeafletListItem {
-
content: LeafletListContent;
-
children?: LeafletListItem[];
}
-
export type LeafletListContent = LeafletTextBlock | LeafletHeaderBlock | LeafletImageBlock;
export interface LeafletWebsiteBlock {
-
$type?: "pub.leaflet.blocks.website";
-
src: string;
-
title?: string;
-
description?: string;
-
previewImage?: LeafletBlobRef;
}
export interface LeafletIFrameBlock {
-
$type?: "pub.leaflet.blocks.iframe";
-
url: string;
-
height?: number;
}
export interface LeafletMathBlock {
-
$type?: "pub.leaflet.blocks.math";
-
tex: string;
}
export interface LeafletCodeBlock {
-
$type?: "pub.leaflet.blocks.code";
-
plaintext: string;
-
language?: string;
-
syntaxHighlightingTheme?: string;
}
export interface LeafletHorizontalRuleBlock {
-
$type?: "pub.leaflet.blocks.horizontalRule";
}
export interface LeafletBskyPostBlock {
-
$type?: "pub.leaflet.blocks.bskyPost";
-
postRef: StrongRef;
}
export interface LeafletRichTextFacet {
-
index: LeafletByteSlice;
-
features: LeafletRichTextFeature[];
}
export interface LeafletByteSlice {
-
byteStart: number;
-
byteEnd: number;
}
export type LeafletRichTextFeature =
-
| LeafletRichTextLinkFeature
-
| LeafletRichTextCodeFeature
-
| LeafletRichTextHighlightFeature
-
| LeafletRichTextUnderlineFeature
-
| LeafletRichTextStrikethroughFeature
-
| LeafletRichTextIdFeature
-
| LeafletRichTextBoldFeature
-
| LeafletRichTextItalicFeature;
export interface LeafletRichTextLinkFeature {
-
$type: "pub.leaflet.richtext.facet#link";
-
uri: string;
}
export interface LeafletRichTextCodeFeature {
-
$type: "pub.leaflet.richtext.facet#code";
}
export interface LeafletRichTextHighlightFeature {
-
$type: "pub.leaflet.richtext.facet#highlight";
}
export interface LeafletRichTextUnderlineFeature {
-
$type: "pub.leaflet.richtext.facet#underline";
}
export interface LeafletRichTextStrikethroughFeature {
-
$type: "pub.leaflet.richtext.facet#strikethrough";
}
export interface LeafletRichTextIdFeature {
-
$type: "pub.leaflet.richtext.facet#id";
-
id?: string;
}
export interface LeafletRichTextBoldFeature {
-
$type: "pub.leaflet.richtext.facet#bold";
}
export interface LeafletRichTextItalicFeature {
-
$type: "pub.leaflet.richtext.facet#italic";
}
export interface LeafletBlobRef {
-
$type?: string;
-
ref?: {
-
$link?: string;
-
};
-
cid?: string;
-
mimeType?: string;
-
size?: number;
}
export interface LeafletPublicationRecord {
-
$type?: "pub.leaflet.publication";
-
name: string;
-
base_path?: string;
-
description?: string;
-
icon?: LeafletBlobRef;
-
theme?: LeafletTheme;
-
preferences?: LeafletPublicationPreferences;
}
export interface LeafletPublicationPreferences {
-
showInDiscover?: boolean;
-
showComments?: boolean;
}
export interface LeafletTheme {
-
backgroundColor?: LeafletThemeColor;
-
backgroundImage?: LeafletThemeBackgroundImage;
-
primary?: LeafletThemeColor;
-
pageBackground?: LeafletThemeColor;
-
showPageBackground?: boolean;
-
accentBackground?: LeafletThemeColor;
-
accentText?: LeafletThemeColor;
}
export type LeafletThemeColor = LeafletThemeColorRgb | LeafletThemeColorRgba;
export interface LeafletThemeColorRgb {
-
$type?: "pub.leaflet.theme.color#rgb";
-
r: number;
-
g: number;
-
b: number;
}
export interface LeafletThemeColorRgba {
-
$type?: "pub.leaflet.theme.color#rgba";
-
r: number;
-
g: number;
-
b: number;
-
a: number;
}
export interface LeafletThemeBackgroundImage {
-
$type?: "pub.leaflet.theme.backgroundImage";
-
image: LeafletBlobRef;
-
width?: number;
-
repeat?: boolean;
}
-
export type LeafletInlineRenderable = LeafletTextBlock | LeafletHeaderBlock | LeafletBlockquoteBlock;
···
export interface StrongRef {
+
uri: string;
+
cid: string;
}
export interface LeafletDocumentRecord {
+
$type?: "pub.leaflet.document";
+
title: string;
+
postRef?: StrongRef;
+
description?: string;
+
publishedAt?: string;
+
publication: string;
+
author: string;
+
pages: LeafletDocumentPage[];
}
export type LeafletDocumentPage = LeafletLinearDocumentPage;
export interface LeafletLinearDocumentPage {
+
$type?: "pub.leaflet.pages.linearDocument";
+
blocks?: LeafletLinearDocumentBlock[];
}
export type LeafletAlignmentValue =
+
| "#textAlignLeft"
+
| "#textAlignCenter"
+
| "#textAlignRight"
+
| "#textAlignJustify"
+
| "textAlignLeft"
+
| "textAlignCenter"
+
| "textAlignRight"
+
| "textAlignJustify";
export interface LeafletLinearDocumentBlock {
+
block: LeafletBlock;
+
alignment?: LeafletAlignmentValue;
}
export type LeafletBlock =
+
| LeafletTextBlock
+
| LeafletHeaderBlock
+
| LeafletBlockquoteBlock
+
| LeafletImageBlock
+
| LeafletUnorderedListBlock
+
| LeafletWebsiteBlock
+
| LeafletIFrameBlock
+
| LeafletMathBlock
+
| LeafletCodeBlock
+
| LeafletHorizontalRuleBlock
+
| LeafletBskyPostBlock;
export interface LeafletBaseTextBlock {
+
plaintext: string;
+
facets?: LeafletRichTextFacet[];
}
export interface LeafletTextBlock extends LeafletBaseTextBlock {
+
$type?: "pub.leaflet.blocks.text";
}
export interface LeafletHeaderBlock extends LeafletBaseTextBlock {
+
$type?: "pub.leaflet.blocks.header";
+
level?: number;
}
export interface LeafletBlockquoteBlock extends LeafletBaseTextBlock {
+
$type?: "pub.leaflet.blocks.blockquote";
}
export interface LeafletImageBlock {
+
$type?: "pub.leaflet.blocks.image";
+
image: LeafletBlobRef;
+
alt?: string;
+
aspectRatio: {
+
width: number;
+
height: number;
+
};
}
export interface LeafletUnorderedListBlock {
+
$type?: "pub.leaflet.blocks.unorderedList";
+
children: LeafletListItem[];
}
export interface LeafletListItem {
+
content: LeafletListContent;
+
children?: LeafletListItem[];
}
+
export type LeafletListContent =
+
| LeafletTextBlock
+
| LeafletHeaderBlock
+
| LeafletImageBlock;
export interface LeafletWebsiteBlock {
+
$type?: "pub.leaflet.blocks.website";
+
src: string;
+
title?: string;
+
description?: string;
+
previewImage?: LeafletBlobRef;
}
export interface LeafletIFrameBlock {
+
$type?: "pub.leaflet.blocks.iframe";
+
url: string;
+
height?: number;
}
export interface LeafletMathBlock {
+
$type?: "pub.leaflet.blocks.math";
+
tex: string;
}
export interface LeafletCodeBlock {
+
$type?: "pub.leaflet.blocks.code";
+
plaintext: string;
+
language?: string;
+
syntaxHighlightingTheme?: string;
}
export interface LeafletHorizontalRuleBlock {
+
$type?: "pub.leaflet.blocks.horizontalRule";
}
export interface LeafletBskyPostBlock {
+
$type?: "pub.leaflet.blocks.bskyPost";
+
postRef: StrongRef;
}
export interface LeafletRichTextFacet {
+
index: LeafletByteSlice;
+
features: LeafletRichTextFeature[];
}
export interface LeafletByteSlice {
+
byteStart: number;
+
byteEnd: number;
}
export type LeafletRichTextFeature =
+
| LeafletRichTextLinkFeature
+
| LeafletRichTextCodeFeature
+
| LeafletRichTextHighlightFeature
+
| LeafletRichTextUnderlineFeature
+
| LeafletRichTextStrikethroughFeature
+
| LeafletRichTextIdFeature
+
| LeafletRichTextBoldFeature
+
| LeafletRichTextItalicFeature;
export interface LeafletRichTextLinkFeature {
+
$type: "pub.leaflet.richtext.facet#link";
+
uri: string;
}
export interface LeafletRichTextCodeFeature {
+
$type: "pub.leaflet.richtext.facet#code";
}
export interface LeafletRichTextHighlightFeature {
+
$type: "pub.leaflet.richtext.facet#highlight";
}
export interface LeafletRichTextUnderlineFeature {
+
$type: "pub.leaflet.richtext.facet#underline";
}
export interface LeafletRichTextStrikethroughFeature {
+
$type: "pub.leaflet.richtext.facet#strikethrough";
}
export interface LeafletRichTextIdFeature {
+
$type: "pub.leaflet.richtext.facet#id";
+
id?: string;
}
export interface LeafletRichTextBoldFeature {
+
$type: "pub.leaflet.richtext.facet#bold";
}
export interface LeafletRichTextItalicFeature {
+
$type: "pub.leaflet.richtext.facet#italic";
}
export interface LeafletBlobRef {
+
$type?: string;
+
ref?: {
+
$link?: string;
+
};
+
cid?: string;
+
mimeType?: string;
+
size?: number;
}
export interface LeafletPublicationRecord {
+
$type?: "pub.leaflet.publication";
+
name: string;
+
base_path?: string;
+
description?: string;
+
icon?: LeafletBlobRef;
+
theme?: LeafletTheme;
+
preferences?: LeafletPublicationPreferences;
}
export interface LeafletPublicationPreferences {
+
showInDiscover?: boolean;
+
showComments?: boolean;
}
export interface LeafletTheme {
+
backgroundColor?: LeafletThemeColor;
+
backgroundImage?: LeafletThemeBackgroundImage;
+
primary?: LeafletThemeColor;
+
pageBackground?: LeafletThemeColor;
+
showPageBackground?: boolean;
+
accentBackground?: LeafletThemeColor;
+
accentText?: LeafletThemeColor;
}
export type LeafletThemeColor = LeafletThemeColorRgb | LeafletThemeColorRgba;
export interface LeafletThemeColorRgb {
+
$type?: "pub.leaflet.theme.color#rgb";
+
r: number;
+
g: number;
+
b: number;
}
export interface LeafletThemeColorRgba {
+
$type?: "pub.leaflet.theme.color#rgba";
+
r: number;
+
g: number;
+
b: number;
+
a: number;
}
export interface LeafletThemeBackgroundImage {
+
$type?: "pub.leaflet.theme.backgroundImage";
+
image: LeafletBlobRef;
+
width?: number;
+
repeat?: boolean;
}
+
export type LeafletInlineRenderable =
+
| LeafletTextBlock
+
| LeafletHeaderBlock
+
| LeafletBlockquoteBlock;
+34 -27
lib/utils/at-uri.ts
···
export interface ParsedAtUri {
-
did: string;
-
collection: string;
-
rkey: string;
}
export function parseAtUri(uri?: string): ParsedAtUri | undefined {
-
if (!uri || !uri.startsWith('at://')) return undefined;
-
const withoutScheme = uri.slice('at://'.length);
-
const [did, collection, rkey] = withoutScheme.split('/');
-
if (!did || !collection || !rkey) return undefined;
-
return { did, collection, rkey };
}
export function toBlueskyPostUrl(target: ParsedAtUri): string | undefined {
-
if (target.collection !== 'app.bsky.feed.post') return undefined;
-
return `https://bsky.app/profile/${target.did}/post/${target.rkey}`;
}
export function formatDidForLabel(did: string): string {
-
return did.replace(/^did:(plc:)?/, '');
}
const ABSOLUTE_URL_PATTERN = /^[a-zA-Z][a-zA-Z\d+\-.]*:/;
-
export function normalizeLeafletBasePath(basePath?: string): string | undefined {
-
if (!basePath) return undefined;
-
const trimmed = basePath.trim();
-
if (!trimmed) return undefined;
-
const withScheme = ABSOLUTE_URL_PATTERN.test(trimmed) ? trimmed : `https://${trimmed}`;
-
try {
-
const url = new URL(withScheme);
-
url.hash = '';
-
return url.href.replace(/\/?$/, '');
-
} catch {
-
return undefined;
-
}
}
-
export function leafletRkeyUrl(basePath: string | undefined, rkey: string): string | undefined {
-
const normalized = normalizeLeafletBasePath(basePath);
-
if (!normalized) return undefined;
-
return `${normalized}/${encodeURIComponent(rkey)}`;
}
···
export interface ParsedAtUri {
+
did: string;
+
collection: string;
+
rkey: string;
}
export function parseAtUri(uri?: string): ParsedAtUri | undefined {
+
if (!uri || !uri.startsWith("at://")) return undefined;
+
const withoutScheme = uri.slice("at://".length);
+
const [did, collection, rkey] = withoutScheme.split("/");
+
if (!did || !collection || !rkey) return undefined;
+
return { did, collection, rkey };
}
export function toBlueskyPostUrl(target: ParsedAtUri): string | undefined {
+
if (target.collection !== "app.bsky.feed.post") return undefined;
+
return `https://bsky.app/profile/${target.did}/post/${target.rkey}`;
}
export function formatDidForLabel(did: string): string {
+
return did.replace(/^did:(plc:)?/, "");
}
const ABSOLUTE_URL_PATTERN = /^[a-zA-Z][a-zA-Z\d+\-.]*:/;
+
export function normalizeLeafletBasePath(
+
basePath?: string,
+
): string | undefined {
+
if (!basePath) return undefined;
+
const trimmed = basePath.trim();
+
if (!trimmed) return undefined;
+
const withScheme = ABSOLUTE_URL_PATTERN.test(trimmed)
+
? trimmed
+
: `https://${trimmed}`;
+
try {
+
const url = new URL(withScheme);
+
url.hash = "";
+
return url.href.replace(/\/?$/, "");
+
} catch {
+
return undefined;
+
}
}
+
export function leafletRkeyUrl(
+
basePath: string | undefined,
+
rkey: string,
+
): string | undefined {
+
const normalized = normalizeLeafletBasePath(basePath);
+
if (!normalized) return undefined;
+
return `${normalized}/${encodeURIComponent(rkey)}`;
}
+185 -132
lib/utils/atproto-client.ts
···
-
import { Client, simpleFetchHandler, type FetchHandler } from '@atcute/client';
-
import { CompositeDidDocumentResolver, PlcDidDocumentResolver, WebDidDocumentResolver, XrpcHandleResolver } from '@atcute/identity-resolver';
-
import type { DidDocument } from '@atcute/identity';
-
import type { Did, Handle } from '@atcute/lexicons/syntax';
-
import type {} from '@atcute/tangled';
-
import type {} from '@atcute/atproto';
export interface ServiceResolverOptions {
-
plcDirectory?: string;
-
identityService?: string;
-
fetch?: typeof fetch;
}
-
const DEFAULT_PLC = 'https://plc.directory';
-
const DEFAULT_IDENTITY_SERVICE = 'https://public.api.bsky.app';
const ABSOLUTE_URL_RE = /^[a-zA-Z][a-zA-Z\d+\-.]*:/;
-
const SUPPORTED_DID_METHODS = ['plc', 'web'] as const;
type SupportedDidMethod = (typeof SUPPORTED_DID_METHODS)[number];
type SupportedDid = Did<SupportedDidMethod>;
-
export const SLINGSHOT_BASE_URL = 'https://slingshot.microcosm.blue';
export const normalizeBaseUrl = (input: string): string => {
-
const trimmed = input.trim();
-
if (!trimmed) throw new Error('Service URL cannot be empty');
-
const withScheme = ABSOLUTE_URL_RE.test(trimmed) ? trimmed : `https://${trimmed.replace(/^\/+/, '')}`;
-
const url = new URL(withScheme);
-
const pathname = url.pathname.replace(/\/+$/, '');
-
return pathname ? `${url.origin}${pathname}` : url.origin;
};
export class ServiceResolver {
-
private plc: string;
-
private didResolver: CompositeDidDocumentResolver<SupportedDidMethod>;
-
private handleResolver: XrpcHandleResolver;
-
private fetchImpl: typeof fetch;
-
constructor(opts: ServiceResolverOptions = {}) {
-
const plcSource = opts.plcDirectory && opts.plcDirectory.trim() ? opts.plcDirectory : DEFAULT_PLC;
-
const identitySource = opts.identityService && opts.identityService.trim() ? opts.identityService : DEFAULT_IDENTITY_SERVICE;
-
this.plc = normalizeBaseUrl(plcSource);
-
const identityBase = normalizeBaseUrl(identitySource);
-
this.fetchImpl = bindFetch(opts.fetch);
-
const plcResolver = new PlcDidDocumentResolver({ apiUrl: this.plc, fetch: this.fetchImpl });
-
const webResolver = new WebDidDocumentResolver({ fetch: this.fetchImpl });
-
this.didResolver = new CompositeDidDocumentResolver({ methods: { plc: plcResolver, web: webResolver } });
-
this.handleResolver = new XrpcHandleResolver({ serviceUrl: identityBase, fetch: this.fetchImpl });
-
}
-
async resolveDidDoc(did: string): Promise<DidDocument> {
-
const trimmed = did.trim();
-
if (!trimmed.startsWith('did:')) throw new Error(`Invalid DID ${did}`);
-
const methodEnd = trimmed.indexOf(':', 4);
-
const method = (methodEnd === -1 ? trimmed.slice(4) : trimmed.slice(4, methodEnd)) as string;
-
if (!SUPPORTED_DID_METHODS.includes(method as SupportedDidMethod)) {
-
throw new Error(`Unsupported DID method ${method ?? '<unknown>'}`);
-
}
-
return this.didResolver.resolve(trimmed as SupportedDid);
-
}
-
async pdsEndpointForDid(did: string): Promise<string> {
-
const doc = await this.resolveDidDoc(did);
-
const svc = doc.service?.find(s => s.type === 'AtprotoPersonalDataServer');
-
if (!svc || !svc.serviceEndpoint || typeof svc.serviceEndpoint !== 'string') {
-
throw new Error(`No PDS endpoint in DID doc for ${did}`);
-
}
-
return svc.serviceEndpoint.replace(/\/$/, '');
-
}
-
async resolveHandle(handle: string): Promise<string> {
-
const normalized = handle.trim().toLowerCase();
-
if (!normalized) throw new Error('Handle cannot be empty');
-
let slingshotError: Error | undefined;
-
try {
-
const url = new URL('/xrpc/com.atproto.identity.resolveHandle', SLINGSHOT_BASE_URL);
-
url.searchParams.set('handle', normalized);
-
const response = await this.fetchImpl(url);
-
if (response.ok) {
-
const payload = await response.json() as { did?: string } | null;
-
if (payload?.did) {
-
return payload.did;
-
}
-
slingshotError = new Error('Slingshot resolveHandle response missing DID');
-
} else {
-
slingshotError = new Error(`Slingshot resolveHandle failed with status ${response.status}`);
-
const body = response.body;
-
if (body) {
-
body.cancel().catch(() => {});
-
}
-
}
-
} catch (err) {
-
if (err instanceof DOMException && err.name === 'AbortError') throw err;
-
slingshotError = err instanceof Error ? err : new Error(String(err));
-
}
-
try {
-
const did = await this.handleResolver.resolve(normalized as Handle);
-
return did;
-
} catch (err) {
-
if (slingshotError && err instanceof Error) {
-
const prior = err.message;
-
err.message = `${prior}; Slingshot resolveHandle failed: ${slingshotError.message}`;
-
}
-
throw err;
-
}
-
}
}
export interface CreateClientOptions extends ServiceResolverOptions {
-
did?: string; // optional to create a DID-scoped client
-
service?: string; // override service base url
}
export async function createAtprotoClient(opts: CreateClientOptions = {}) {
-
const fetchImpl = bindFetch(opts.fetch);
-
let service = opts.service;
-
const resolver = new ServiceResolver({ ...opts, fetch: fetchImpl });
-
if (!service && opts.did) {
-
service = await resolver.pdsEndpointForDid(opts.did);
-
}
-
if (!service) throw new Error('service or did required');
-
const normalizedService = normalizeBaseUrl(service);
-
const handler = createSlingshotAwareHandler(normalizedService, fetchImpl);
-
const rpc = new Client({ handler });
-
return { rpc, service: normalizedService, resolver };
}
-
export type AtprotoClient = Awaited<ReturnType<typeof createAtprotoClient>>['rpc'];
const SLINGSHOT_RETRY_PATHS = [
-
'/xrpc/com.atproto.repo.getRecord',
-
'/xrpc/com.atproto.identity.resolveHandle',
];
-
function createSlingshotAwareHandler(service: string, fetchImpl: typeof fetch): FetchHandler {
-
const primary = simpleFetchHandler({ service, fetch: fetchImpl });
-
const slingshot = simpleFetchHandler({ service: SLINGSHOT_BASE_URL, fetch: fetchImpl });
-
return async (pathname, init) => {
-
const matched = SLINGSHOT_RETRY_PATHS.find(candidate => pathname === candidate || pathname.startsWith(`${candidate}?`));
-
if (matched) {
-
try {
-
const slingshotResponse = await slingshot(pathname, init);
-
if (slingshotResponse.ok) {
-
return slingshotResponse;
-
}
-
const body = slingshotResponse.body;
-
if (body) {
-
body.cancel().catch(() => {});
-
}
-
} catch (err) {
-
if (err instanceof DOMException && err.name === 'AbortError') {
-
throw err;
-
}
-
}
-
}
-
return primary(pathname, init);
-
};
}
function bindFetch(fetchImpl?: typeof fetch): typeof fetch {
-
const impl = fetchImpl ?? globalThis.fetch;
-
if (typeof impl !== 'function') {
-
throw new Error('fetch implementation not available');
-
}
-
return impl.bind(globalThis);
}
···
+
import { Client, simpleFetchHandler, type FetchHandler } from "@atcute/client";
+
import {
+
CompositeDidDocumentResolver,
+
PlcDidDocumentResolver,
+
WebDidDocumentResolver,
+
XrpcHandleResolver,
+
} from "@atcute/identity-resolver";
+
import type { DidDocument } from "@atcute/identity";
+
import type { Did, Handle } from "@atcute/lexicons/syntax";
+
import type {} from "@atcute/tangled";
+
import type {} from "@atcute/atproto";
export interface ServiceResolverOptions {
+
plcDirectory?: string;
+
identityService?: string;
+
fetch?: typeof fetch;
}
+
const DEFAULT_PLC = "https://plc.directory";
+
const DEFAULT_IDENTITY_SERVICE = "https://public.api.bsky.app";
const ABSOLUTE_URL_RE = /^[a-zA-Z][a-zA-Z\d+\-.]*:/;
+
const SUPPORTED_DID_METHODS = ["plc", "web"] as const;
type SupportedDidMethod = (typeof SUPPORTED_DID_METHODS)[number];
type SupportedDid = Did<SupportedDidMethod>;
+
export const SLINGSHOT_BASE_URL = "https://slingshot.microcosm.blue";
export const normalizeBaseUrl = (input: string): string => {
+
const trimmed = input.trim();
+
if (!trimmed) throw new Error("Service URL cannot be empty");
+
const withScheme = ABSOLUTE_URL_RE.test(trimmed)
+
? trimmed
+
: `https://${trimmed.replace(/^\/+/, "")}`;
+
const url = new URL(withScheme);
+
const pathname = url.pathname.replace(/\/+$/, "");
+
return pathname ? `${url.origin}${pathname}` : url.origin;
};
export class ServiceResolver {
+
private plc: string;
+
private didResolver: CompositeDidDocumentResolver<SupportedDidMethod>;
+
private handleResolver: XrpcHandleResolver;
+
private fetchImpl: typeof fetch;
+
constructor(opts: ServiceResolverOptions = {}) {
+
const plcSource =
+
opts.plcDirectory && opts.plcDirectory.trim()
+
? opts.plcDirectory
+
: DEFAULT_PLC;
+
const identitySource =
+
opts.identityService && opts.identityService.trim()
+
? opts.identityService
+
: DEFAULT_IDENTITY_SERVICE;
+
this.plc = normalizeBaseUrl(plcSource);
+
const identityBase = normalizeBaseUrl(identitySource);
+
this.fetchImpl = bindFetch(opts.fetch);
+
const plcResolver = new PlcDidDocumentResolver({
+
apiUrl: this.plc,
+
fetch: this.fetchImpl,
+
});
+
const webResolver = new WebDidDocumentResolver({
+
fetch: this.fetchImpl,
+
});
+
this.didResolver = new CompositeDidDocumentResolver({
+
methods: { plc: plcResolver, web: webResolver },
+
});
+
this.handleResolver = new XrpcHandleResolver({
+
serviceUrl: identityBase,
+
fetch: this.fetchImpl,
+
});
+
}
+
async resolveDidDoc(did: string): Promise<DidDocument> {
+
const trimmed = did.trim();
+
if (!trimmed.startsWith("did:")) throw new Error(`Invalid DID ${did}`);
+
const methodEnd = trimmed.indexOf(":", 4);
+
const method = (
+
methodEnd === -1 ? trimmed.slice(4) : trimmed.slice(4, methodEnd)
+
) as string;
+
if (!SUPPORTED_DID_METHODS.includes(method as SupportedDidMethod)) {
+
throw new Error(`Unsupported DID method ${method ?? "<unknown>"}`);
+
}
+
return this.didResolver.resolve(trimmed as SupportedDid);
+
}
+
async pdsEndpointForDid(did: string): Promise<string> {
+
const doc = await this.resolveDidDoc(did);
+
const svc = doc.service?.find(
+
(s) => s.type === "AtprotoPersonalDataServer",
+
);
+
if (
+
!svc ||
+
!svc.serviceEndpoint ||
+
typeof svc.serviceEndpoint !== "string"
+
) {
+
throw new Error(`No PDS endpoint in DID doc for ${did}`);
+
}
+
return svc.serviceEndpoint.replace(/\/$/, "");
+
}
+
async resolveHandle(handle: string): Promise<string> {
+
const normalized = handle.trim().toLowerCase();
+
if (!normalized) throw new Error("Handle cannot be empty");
+
let slingshotError: Error | undefined;
+
try {
+
const url = new URL(
+
"/xrpc/com.atproto.identity.resolveHandle",
+
SLINGSHOT_BASE_URL,
+
);
+
url.searchParams.set("handle", normalized);
+
const response = await this.fetchImpl(url);
+
if (response.ok) {
+
const payload = (await response.json()) as {
+
did?: string;
+
} | null;
+
if (payload?.did) {
+
return payload.did;
+
}
+
slingshotError = new Error(
+
"Slingshot resolveHandle response missing DID",
+
);
+
} else {
+
slingshotError = new Error(
+
`Slingshot resolveHandle failed with status ${response.status}`,
+
);
+
const body = response.body;
+
if (body) {
+
body.cancel().catch(() => {});
+
}
+
}
+
} catch (err) {
+
if (err instanceof DOMException && err.name === "AbortError")
+
throw err;
+
slingshotError =
+
err instanceof Error ? err : new Error(String(err));
+
}
+
try {
+
const did = await this.handleResolver.resolve(normalized as Handle);
+
return did;
+
} catch (err) {
+
if (slingshotError && err instanceof Error) {
+
const prior = err.message;
+
err.message = `${prior}; Slingshot resolveHandle failed: ${slingshotError.message}`;
+
}
+
throw err;
+
}
+
}
}
export interface CreateClientOptions extends ServiceResolverOptions {
+
did?: string; // optional to create a DID-scoped client
+
service?: string; // override service base url
}
export async function createAtprotoClient(opts: CreateClientOptions = {}) {
+
const fetchImpl = bindFetch(opts.fetch);
+
let service = opts.service;
+
const resolver = new ServiceResolver({ ...opts, fetch: fetchImpl });
+
if (!service && opts.did) {
+
service = await resolver.pdsEndpointForDid(opts.did);
+
}
+
if (!service) throw new Error("service or did required");
+
const normalizedService = normalizeBaseUrl(service);
+
const handler = createSlingshotAwareHandler(normalizedService, fetchImpl);
+
const rpc = new Client({ handler });
+
return { rpc, service: normalizedService, resolver };
}
+
export type AtprotoClient = Awaited<
+
ReturnType<typeof createAtprotoClient>
+
>["rpc"];
const SLINGSHOT_RETRY_PATHS = [
+
"/xrpc/com.atproto.repo.getRecord",
+
"/xrpc/com.atproto.identity.resolveHandle",
];
+
function createSlingshotAwareHandler(
+
service: string,
+
fetchImpl: typeof fetch,
+
): FetchHandler {
+
const primary = simpleFetchHandler({ service, fetch: fetchImpl });
+
const slingshot = simpleFetchHandler({
+
service: SLINGSHOT_BASE_URL,
+
fetch: fetchImpl,
+
});
+
return async (pathname, init) => {
+
const matched = SLINGSHOT_RETRY_PATHS.find(
+
(candidate) =>
+
pathname === candidate || pathname.startsWith(`${candidate}?`),
+
);
+
if (matched) {
+
try {
+
const slingshotResponse = await slingshot(pathname, init);
+
if (slingshotResponse.ok) {
+
return slingshotResponse;
+
}
+
const body = slingshotResponse.body;
+
if (body) {
+
body.cancel().catch(() => {});
+
}
+
} catch (err) {
+
if (err instanceof DOMException && err.name === "AbortError") {
+
throw err;
+
}
+
}
+
}
+
return primary(pathname, init);
+
};
}
function bindFetch(fetchImpl?: typeof fetch): typeof fetch {
+
const impl = fetchImpl ?? globalThis.fetch;
+
if (typeof impl !== "function") {
+
throw new Error("fetch implementation not available");
+
}
+
return impl.bind(globalThis);
}
+60 -21
lib/utils/cache.ts
···
-
import type { DidDocument } from '@atcute/identity';
-
import { ServiceResolver } from './atproto-client';
interface DidCacheEntry {
did: string;
···
pdsEndpoint?: string;
}
-
const toSnapshot = (entry: DidCacheEntry | undefined): DidCacheSnapshot | undefined => {
if (!entry) return undefined;
const { did, handle, doc, pdsEndpoint } = entry;
return { did, handle, doc, pdsEndpoint };
};
-
const derivePdsEndpoint = (doc: DidDocument | undefined): string | undefined => {
if (!doc?.service) return undefined;
-
const svc = doc.service.find(service => service.type === 'AtprotoPersonalDataServer');
if (!svc) return undefined;
-
const endpoint = typeof svc.serviceEndpoint === 'string' ? svc.serviceEndpoint : undefined;
if (!endpoint) return undefined;
-
return endpoint.replace(/\/$/, '');
};
export class DidCache {
···
return toSnapshot(this.byDid.get(did));
}
-
memoize(entry: { did: string; handle?: string; doc?: DidDocument; pdsEndpoint?: string }): DidCacheSnapshot {
const did = entry.did;
const normalizedHandle = entry.handle?.toLowerCase();
-
const existing = this.byDid.get(did) ?? (normalizedHandle ? this.byHandle.get(normalizedHandle) : undefined);
const doc = entry.doc ?? existing?.doc;
const handle = normalizedHandle ?? existing?.handle;
-
const pdsEndpoint = entry.pdsEndpoint ?? derivePdsEndpoint(doc) ?? existing?.pdsEndpoint;
const merged: DidCacheEntry = {
did,
···
return toSnapshot(merged) as DidCacheSnapshot;
}
-
ensureHandle(resolver: ServiceResolver, handle: string): Promise<DidCacheSnapshot> {
const normalized = handle.toLowerCase();
const cached = this.getByHandle(normalized);
if (cached?.did) return Promise.resolve(cached);
···
if (pending) return pending;
const promise = resolver
.resolveHandle(normalized)
-
.then(did => this.memoize({ did, handle: normalized }))
.finally(() => {
this.handlePromises.delete(normalized);
});
···
return promise;
}
-
ensureDidDoc(resolver: ServiceResolver, did: string): Promise<DidCacheSnapshot> {
const cached = this.getByDid(did);
-
if (cached?.doc && cached.handle !== undefined) return Promise.resolve(cached);
const pending = this.docPromises.get(did);
if (pending) return pending;
const promise = resolver
.resolveDidDoc(did)
-
.then(doc => {
-
const aka = doc.alsoKnownAs?.find(a => a.startsWith('at://'));
-
const handle = aka ? aka.replace('at://', '').toLowerCase() : cached?.handle;
return this.memoize({ did, handle, doc });
})
.finally(() => {
···
return promise;
}
-
ensurePdsEndpoint(resolver: ServiceResolver, did: string): Promise<DidCacheSnapshot> {
const cached = this.getByDid(did);
if (cached?.pdsEndpoint) return Promise.resolve(cached);
const pending = this.pdsPromises.get(did);
if (pending) return pending;
const promise = (async () => {
-
const docSnapshot = await this.ensureDidDoc(resolver, did).catch(() => undefined);
if (docSnapshot?.pdsEndpoint) return docSnapshot;
const endpoint = await resolver.pdsEndpointForDid(did);
return this.memoize({ did, pdsEndpoint: endpoint });
···
this.store.set(this.key(did, cid), { blob, timestamp: Date.now() });
}
-
ensure(did: string, cid: string, loader: () => { promise: Promise<Blob>; abort: () => void }): EnsureResult {
const cached = this.get(did, cid);
if (cached) {
return { promise: Promise.resolve(cached), release: () => {} };
···
}
const { promise, abort } = loader();
-
const wrapped = promise.then(blob => {
this.set(did, cid, blob);
return blob;
});
···
+
import type { DidDocument } from "@atcute/identity";
+
import { ServiceResolver } from "./atproto-client";
interface DidCacheEntry {
did: string;
···
pdsEndpoint?: string;
}
+
const toSnapshot = (
+
entry: DidCacheEntry | undefined,
+
): DidCacheSnapshot | undefined => {
if (!entry) return undefined;
const { did, handle, doc, pdsEndpoint } = entry;
return { did, handle, doc, pdsEndpoint };
};
+
const derivePdsEndpoint = (
+
doc: DidDocument | undefined,
+
): string | undefined => {
if (!doc?.service) return undefined;
+
const svc = doc.service.find(
+
(service) => service.type === "AtprotoPersonalDataServer",
+
);
if (!svc) return undefined;
+
const endpoint =
+
typeof svc.serviceEndpoint === "string"
+
? svc.serviceEndpoint
+
: undefined;
if (!endpoint) return undefined;
+
return endpoint.replace(/\/$/, "");
};
export class DidCache {
···
return toSnapshot(this.byDid.get(did));
}
+
memoize(entry: {
+
did: string;
+
handle?: string;
+
doc?: DidDocument;
+
pdsEndpoint?: string;
+
}): DidCacheSnapshot {
const did = entry.did;
const normalizedHandle = entry.handle?.toLowerCase();
+
const existing =
+
this.byDid.get(did) ??
+
(normalizedHandle
+
? this.byHandle.get(normalizedHandle)
+
: undefined);
const doc = entry.doc ?? existing?.doc;
const handle = normalizedHandle ?? existing?.handle;
+
const pdsEndpoint =
+
entry.pdsEndpoint ??
+
derivePdsEndpoint(doc) ??
+
existing?.pdsEndpoint;
const merged: DidCacheEntry = {
did,
···
return toSnapshot(merged) as DidCacheSnapshot;
}
+
ensureHandle(
+
resolver: ServiceResolver,
+
handle: string,
+
): Promise<DidCacheSnapshot> {
const normalized = handle.toLowerCase();
const cached = this.getByHandle(normalized);
if (cached?.did) return Promise.resolve(cached);
···
if (pending) return pending;
const promise = resolver
.resolveHandle(normalized)
+
.then((did) => this.memoize({ did, handle: normalized }))
.finally(() => {
this.handlePromises.delete(normalized);
});
···
return promise;
}
+
ensureDidDoc(
+
resolver: ServiceResolver,
+
did: string,
+
): Promise<DidCacheSnapshot> {
const cached = this.getByDid(did);
+
if (cached?.doc && cached.handle !== undefined)
+
return Promise.resolve(cached);
const pending = this.docPromises.get(did);
if (pending) return pending;
const promise = resolver
.resolveDidDoc(did)
+
.then((doc) => {
+
const aka = doc.alsoKnownAs?.find((a) => a.startsWith("at://"));
+
const handle = aka
+
? aka.replace("at://", "").toLowerCase()
+
: cached?.handle;
return this.memoize({ did, handle, doc });
})
.finally(() => {
···
return promise;
}
+
ensurePdsEndpoint(
+
resolver: ServiceResolver,
+
did: string,
+
): Promise<DidCacheSnapshot> {
const cached = this.getByDid(did);
if (cached?.pdsEndpoint) return Promise.resolve(cached);
const pending = this.pdsPromises.get(did);
if (pending) return pending;
const promise = (async () => {
+
const docSnapshot = await this.ensureDidDoc(resolver, did).catch(
+
() => undefined,
+
);
if (docSnapshot?.pdsEndpoint) return docSnapshot;
const endpoint = await resolver.pdsEndpointForDid(did);
return this.memoize({ did, pdsEndpoint: endpoint });
···
this.store.set(this.key(did, cid), { blob, timestamp: Date.now() });
}
+
ensure(
+
did: string,
+
cid: string,
+
loader: () => { promise: Promise<Blob>; abort: () => void },
+
): EnsureResult {
const cached = this.get(did, cid);
if (cached) {
return { promise: Promise.resolve(cached), release: () => {} };
···
}
const { promise, abort } = loader();
+
const wrapped = promise.then((blob) => {
this.set(did, cid, blob);
return blob;
});
+5 -3
lib/utils/profile.ts
···
-
import type { ProfileRecord } from '../types/bluesky';
interface LegacyBlobRef {
ref?: { $link?: string };
cid?: string;
}
-
export function getAvatarCid(record: ProfileRecord | undefined): string | undefined {
const avatar = record?.avatar as LegacyBlobRef | undefined;
if (!avatar) return undefined;
-
if (typeof avatar.cid === 'string') return avatar.cid;
return avatar.ref?.$link;
}
···
+
import type { ProfileRecord } from "../types/bluesky";
interface LegacyBlobRef {
ref?: { $link?: string };
cid?: string;
}
+
export function getAvatarCid(
+
record: ProfileRecord | undefined,
+
): string | undefined {
const avatar = record?.avatar as LegacyBlobRef | undefined;
if (!avatar) return undefined;
+
if (typeof avatar.cid === "string") return avatar.cid;
return avatar.ref?.$link;
}
+531 -324
src/App.tsx
···
-
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
-
import { AtProtoProvider } from '../lib/providers/AtProtoProvider';
-
import { AtProtoRecord } from '../lib/core/AtProtoRecord';
-
import { TangledString } from '../lib/components/TangledString';
-
import { LeafletDocument } from '../lib/components/LeafletDocument';
-
import { BlueskyProfile } from '../lib/components/BlueskyProfile';
-
import { BlueskyPost, BLUESKY_POST_COLLECTION } from '../lib/components/BlueskyPost';
-
import { BlueskyPostList } from '../lib/components/BlueskyPostList';
-
import { BlueskyQuotePost } from '../lib/components/BlueskyQuotePost';
-
import { useDidResolution } from '../lib/hooks/useDidResolution';
-
import { useLatestRecord } from '../lib/hooks/useLatestRecord';
-
import { ColorSchemeToggle } from '../lib/components/ColorSchemeToggle.tsx';
-
import { useColorScheme, type ColorSchemePreference } from '../lib/hooks/useColorScheme';
-
import type { FeedPostRecord } from '../lib/types/bluesky';
-
const COLOR_SCHEME_STORAGE_KEY = 'atproto-ui-color-scheme';
const basicUsageSnippet = `import { AtProtoProvider, BlueskyPost } from 'atproto-ui';
···
};`;
const codeBlockBase: React.CSSProperties = {
-
fontFamily: 'Menlo, Consolas, "SFMono-Regular", ui-monospace, monospace',
-
fontSize: 12,
-
whiteSpace: 'pre',
-
overflowX: 'auto',
-
borderRadius: 10,
-
padding: '12px 14px',
-
lineHeight: 1.6
};
const FullDemo: React.FC = () => {
-
const handleInputRef = useRef<HTMLInputElement | null>(null);
-
const [submitted, setSubmitted] = useState<string | null>(null);
-
const [colorSchemePreference, setColorSchemePreference] = useState<ColorSchemePreference>(() => {
-
if (typeof window === 'undefined') return 'system';
-
try {
-
const stored = window.localStorage.getItem(COLOR_SCHEME_STORAGE_KEY);
-
if (stored === 'light' || stored === 'dark' || stored === 'system') return stored;
-
} catch {
-
/* ignore */
-
}
-
return 'system';
-
});
-
const scheme = useColorScheme(colorSchemePreference);
-
const { did, loading: resolvingDid } = useDidResolution(submitted ?? undefined);
-
const onSubmit = useCallback<React.FormEventHandler>((e) => {
-
e.preventDefault();
-
const rawValue = handleInputRef.current?.value;
-
const nextValue = rawValue?.trim();
-
if (!nextValue) return;
-
if (handleInputRef.current) {
-
handleInputRef.current.value = nextValue;
-
}
-
setSubmitted(nextValue);
-
}, []);
-
useEffect(() => {
-
if (typeof window === 'undefined') return;
-
try {
-
window.localStorage.setItem(COLOR_SCHEME_STORAGE_KEY, colorSchemePreference);
-
} catch {
-
/* ignore */
-
}
-
}, [colorSchemePreference]);
-
useEffect(() => {
-
if (typeof document === 'undefined') return;
-
const root = document.documentElement;
-
const body = document.body;
-
const prevScheme = root.dataset.colorScheme;
-
const prevBg = body.style.backgroundColor;
-
const prevColor = body.style.color;
-
root.dataset.colorScheme = scheme;
-
body.style.backgroundColor = scheme === 'dark' ? '#020617' : '#f8fafc';
-
body.style.color = scheme === 'dark' ? '#e2e8f0' : '#0f172a';
-
return () => {
-
root.dataset.colorScheme = prevScheme ?? '';
-
body.style.backgroundColor = prevBg;
-
body.style.color = prevColor;
-
};
-
}, [scheme]);
-
const showHandle = submitted && !submitted.startsWith('did:') ? submitted : undefined;
-
const mutedTextColor = useMemo(() => (scheme === 'dark' ? '#94a3b8' : '#555'), [scheme]);
-
const panelStyle = useMemo<React.CSSProperties>(() => ({
-
display: 'flex',
-
flexDirection: 'column',
-
gap: 8,
-
padding: 10,
-
borderRadius: 12,
-
borderColor: scheme === 'dark' ? '#1e293b' : '#e2e8f0',
-
}), [scheme]);
-
const baseTextColor = useMemo(() => (scheme === 'dark' ? '#e2e8f0' : '#0f172a'), [scheme]);
-
const gistPanelStyle = useMemo<React.CSSProperties>(() => ({
-
...panelStyle,
-
padding: 0,
-
border: 'none',
-
background: 'transparent',
-
backdropFilter: 'none',
-
marginTop: 32
-
}), [panelStyle]);
-
const leafletPanelStyle = useMemo<React.CSSProperties>(() => ({
-
...panelStyle,
-
padding: 0,
-
border: 'none',
-
background: 'transparent',
-
backdropFilter: 'none',
-
marginTop: 32,
-
alignItems: 'center'
-
}), [panelStyle]);
-
const primaryGridStyle = useMemo<React.CSSProperties>(() => ({
-
display: 'grid',
-
gap: 32,
-
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))'
-
}), []);
-
const columnStackStyle = useMemo<React.CSSProperties>(() => ({
-
display: 'flex',
-
flexDirection: 'column',
-
gap: 32
-
}), []);
-
const codeBlockStyle = useMemo<React.CSSProperties>(() => ({
-
...codeBlockBase,
-
background: scheme === 'dark' ? '#0b1120' : '#f1f5f9',
-
border: `1px solid ${scheme === 'dark' ? '#1e293b' : '#e2e8f0'}`
-
}), [scheme]);
-
const codeTextStyle = useMemo<React.CSSProperties>(() => ({
-
margin: 0,
-
display: 'block',
-
fontFamily: codeBlockBase.fontFamily,
-
fontSize: 12,
-
lineHeight: 1.6,
-
whiteSpace: 'pre'
-
}), []);
-
const basicCodeRef = useRef<HTMLElement | null>(null);
-
const customCodeRef = useRef<HTMLElement | null>(null);
-
// Latest Bluesky post
-
const {
-
rkey: latestPostRkey,
-
loading: loadingLatestPost,
-
empty: noPosts,
-
error: latestPostError
-
} = useLatestRecord<unknown>(did, BLUESKY_POST_COLLECTION);
-
const quoteSampleDid = 'did:plc:ttdrpj45ibqunmfhdsb4zdwq';
-
const quoteSampleRkey = '3m2prlq6xxc2v';
-
return (
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 20, color: baseTextColor }}>
-
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, alignItems: 'center', justifyContent: 'space-between' }}>
-
<form onSubmit={onSubmit} style={{ display: 'flex', gap: 8, flexWrap: 'wrap', flex: '1 1 320px' }}>
-
<input
-
placeholder="Handle or DID (e.g. alice.bsky.social or did:plc:...)"
-
ref={handleInputRef}
-
style={{ flex: '1 1 260px', padding: '6px 8px', borderRadius: 8, border: '1px solid', borderColor: scheme === 'dark' ? '#1e293b' : '#cbd5f5', background: scheme === 'dark' ? '#0b1120' : '#fff', color: scheme === 'dark' ? '#e2e8f0' : '#0f172a' }}
-
/>
-
<button type="submit" style={{ padding: '6px 16px', borderRadius: 8, border: 'none', background: '#2563eb', color: '#fff', cursor: 'pointer' }}>Load</button>
-
</form>
-
<ColorSchemeToggle value={colorSchemePreference} onChange={setColorSchemePreference} scheme={scheme} />
-
</div>
-
{!submitted && <p style={{ color: mutedTextColor }}>Enter a handle to fetch your profile, latest Bluesky post, a Tangled string, and a Leaflet document.</p>}
-
{submitted && resolvingDid && <p style={{ color: mutedTextColor }}>Resolving DID…</p>}
-
{did && (
-
<>
-
<div style={primaryGridStyle}>
-
<div style={columnStackStyle}>
-
<section style={panelStyle}>
-
<h3 style={sectionHeaderStyle}>Profile</h3>
-
<BlueskyProfile did={did} handle={showHandle} colorScheme={colorSchemePreference} />
-
</section>
-
<section style={panelStyle}>
-
<h3 style={sectionHeaderStyle}>Recent Posts</h3>
-
<BlueskyPostList did={did} colorScheme={colorSchemePreference} />
-
</section>
-
</div>
-
<div style={columnStackStyle}>
-
<section style={panelStyle}>
-
<h3 style={sectionHeaderStyle}>Latest Bluesky Post</h3>
-
{loadingLatestPost && <div style={loadingBox}>Loading latest post…</div>}
-
{latestPostError && <div style={errorBox}>Failed to load latest post.</div>}
-
{noPosts && <div style={{ ...infoBox, color: mutedTextColor }}>No posts found.</div>}
-
{!loadingLatestPost && latestPostRkey && (
-
<BlueskyPost did={did} rkey={latestPostRkey} colorScheme={colorSchemePreference} />
-
)}
-
</section>
-
<section style={panelStyle}>
-
<h3 style={sectionHeaderStyle}>Quote Post Demo</h3>
-
<BlueskyQuotePost did={quoteSampleDid} rkey={quoteSampleRkey} colorScheme={colorSchemePreference} />
-
</section>
-
</div>
-
</div>
-
<section style={gistPanelStyle}>
-
<h3 style={sectionHeaderStyle}>A Tangled String</h3>
-
<TangledString did="nekomimi.pet" rkey="3m2p4gjptg522" colorScheme={colorSchemePreference} />
-
</section>
-
<section style={leafletPanelStyle}>
-
<h3 style={sectionHeaderStyle}>A Leaflet Document.</h3>
-
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
-
<LeafletDocument did={"did:plc:ttdrpj45ibqunmfhdsb4zdwq"} rkey={"3m2seagm2222c"} colorScheme={colorSchemePreference} />
-
</div>
-
</section>
-
</>
-
)}
-
<section style={{ ...panelStyle, marginTop: 32 }}>
-
<h3 style={sectionHeaderStyle}>Build your own component</h3>
-
<p style={{ color: mutedTextColor, margin: '4px 0 8px' }}>
-
Wrap your app with the provider once and drop the ready-made components wherever you need them.
-
</p>
-
<pre style={codeBlockStyle}>
-
<code ref={basicCodeRef} className="language-tsx" style={codeTextStyle}>{basicUsageSnippet}</code>
-
</pre>
-
<p style={{ color: mutedTextColor, margin: '16px 0 8px' }}>
-
Need to make your own component? Compose your own renderer with the hooks and utilities that ship with the library.
-
</p>
-
<pre style={codeBlockStyle}>
-
<code ref={customCodeRef} className="language-tsx" style={codeTextStyle}>{customComponentSnippet}</code>
-
</pre>
-
{did && (
-
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>
-
<p style={{ color: mutedTextColor, margin: 0 }}>
-
Live example with your handle:
-
</p>
-
<LatestPostSummary did={did} handle={showHandle} colorScheme={colorSchemePreference} />
-
</div>
-
)}
-
</section>
-
</div>
-
);
};
-
const LatestPostSummary: React.FC<{ did: string; handle?: string; colorScheme: ColorSchemePreference }> = ({ did, colorScheme }) => {
-
const { record, rkey, loading, error } = useLatestRecord<FeedPostRecord>(did, BLUESKY_POST_COLLECTION);
-
const scheme = useColorScheme(colorScheme);
-
const palette = scheme === 'dark' ? latestSummaryPalette.dark : latestSummaryPalette.light;
-
if (loading) return <div style={palette.muted}>Loading summary…</div>;
-
if (error) return <div style={palette.error}>Failed to load the latest post.</div>;
-
if (!rkey) return <div style={palette.muted}>No posts published yet.</div>;
-
const atProtoProps = record
-
? { record }
-
: { did, collection: 'app.bsky.feed.post', rkey };
-
return (
-
<AtProtoRecord<FeedPostRecord>
-
{...atProtoProps}
-
renderer={({ record: resolvedRecord }) => (
-
<article data-color-scheme={scheme}>
-
<strong>{resolvedRecord?.text ?? 'Empty post'}</strong>
-
</article>
-
)}
-
/>
-
);
};
-
const sectionHeaderStyle: React.CSSProperties = { margin: '4px 0', fontSize: 16 };
const loadingBox: React.CSSProperties = { padding: 8 };
-
const errorBox: React.CSSProperties = { padding: 8, color: 'crimson' };
-
const infoBox: React.CSSProperties = { padding: 8, color: '#555' };
const latestSummaryPalette = {
-
light: {
-
card: {
-
border: '1px solid #e2e8f0',
-
background: '#ffffff',
-
borderRadius: 12,
-
padding: 12,
-
display: 'flex',
-
flexDirection: 'column',
-
gap: 8
-
} satisfies React.CSSProperties,
-
header: {
-
display: 'flex',
-
alignItems: 'baseline',
-
justifyContent: 'space-between',
-
gap: 12,
-
color: '#0f172a'
-
} satisfies React.CSSProperties,
-
time: {
-
fontSize: 12,
-
color: '#64748b'
-
} satisfies React.CSSProperties,
-
text: {
-
margin: 0,
-
color: '#1f2937',
-
whiteSpace: 'pre-wrap'
-
} satisfies React.CSSProperties,
-
link: {
-
color: '#2563eb',
-
fontWeight: 600,
-
fontSize: 12,
-
textDecoration: 'none'
-
} satisfies React.CSSProperties,
-
muted: {
-
color: '#64748b'
-
} satisfies React.CSSProperties,
-
error: {
-
color: 'crimson'
-
} satisfies React.CSSProperties
-
},
-
dark: {
-
card: {
-
border: '1px solid #1e293b',
-
background: '#0f172a',
-
borderRadius: 12,
-
padding: 12,
-
display: 'flex',
-
flexDirection: 'column',
-
gap: 8
-
} satisfies React.CSSProperties,
-
header: {
-
display: 'flex',
-
alignItems: 'baseline',
-
justifyContent: 'space-between',
-
gap: 12,
-
color: '#e2e8f0'
-
} satisfies React.CSSProperties,
-
time: {
-
fontSize: 12,
-
color: '#cbd5f5'
-
} satisfies React.CSSProperties,
-
text: {
-
margin: 0,
-
color: '#e2e8f0',
-
whiteSpace: 'pre-wrap'
-
} satisfies React.CSSProperties,
-
link: {
-
color: '#38bdf8',
-
fontWeight: 600,
-
fontSize: 12,
-
textDecoration: 'none'
-
} satisfies React.CSSProperties,
-
muted: {
-
color: '#94a3b8'
-
} satisfies React.CSSProperties,
-
error: {
-
color: '#f472b6'
-
} satisfies React.CSSProperties
-
}
} as const;
export const App: React.FC = () => {
-
return (
-
<AtProtoProvider>
-
<div style={{ maxWidth: 860, margin: '40px auto', padding: '0 20px', fontFamily: 'system-ui, sans-serif' }}>
-
<h1 style={{ marginTop: 0 }}>atproto-ui Demo</h1>
-
<p style={{ lineHeight: 1.4 }}>A component library for rendering common AT Protocol records for applications such as Bluesky and Tangled.</p>
-
<hr style={{ margin: '32px 0' }} />
-
<FullDemo />
-
</div>
-
</AtProtoProvider>
-
);
};
export default App;
···
+
import React, {
+
useState,
+
useCallback,
+
useEffect,
+
useMemo,
+
useRef,
+
} from "react";
+
import { AtProtoProvider } from "../lib/providers/AtProtoProvider";
+
import { AtProtoRecord } from "../lib/core/AtProtoRecord";
+
import { TangledString } from "../lib/components/TangledString";
+
import { LeafletDocument } from "../lib/components/LeafletDocument";
+
import { BlueskyProfile } from "../lib/components/BlueskyProfile";
+
import {
+
BlueskyPost,
+
BLUESKY_POST_COLLECTION,
+
} from "../lib/components/BlueskyPost";
+
import { BlueskyPostList } from "../lib/components/BlueskyPostList";
+
import { BlueskyQuotePost } from "../lib/components/BlueskyQuotePost";
+
import { useDidResolution } from "../lib/hooks/useDidResolution";
+
import { useLatestRecord } from "../lib/hooks/useLatestRecord";
+
import { ColorSchemeToggle } from "../lib/components/ColorSchemeToggle.tsx";
+
import {
+
useColorScheme,
+
type ColorSchemePreference,
+
} from "../lib/hooks/useColorScheme";
+
import type { FeedPostRecord } from "../lib/types/bluesky";
+
const COLOR_SCHEME_STORAGE_KEY = "atproto-ui-color-scheme";
const basicUsageSnippet = `import { AtProtoProvider, BlueskyPost } from 'atproto-ui';
···
};`;
const codeBlockBase: React.CSSProperties = {
+
fontFamily: 'Menlo, Consolas, "SFMono-Regular", ui-monospace, monospace',
+
fontSize: 12,
+
whiteSpace: "pre",
+
overflowX: "auto",
+
borderRadius: 10,
+
padding: "12px 14px",
+
lineHeight: 1.6,
};
const FullDemo: React.FC = () => {
+
const handleInputRef = useRef<HTMLInputElement | null>(null);
+
const [submitted, setSubmitted] = useState<string | null>(null);
+
const [colorSchemePreference, setColorSchemePreference] =
+
useState<ColorSchemePreference>(() => {
+
if (typeof window === "undefined") return "system";
+
try {
+
const stored = window.localStorage.getItem(
+
COLOR_SCHEME_STORAGE_KEY,
+
);
+
if (
+
stored === "light" ||
+
stored === "dark" ||
+
stored === "system"
+
)
+
return stored;
+
} catch {
+
/* ignore */
+
}
+
return "system";
+
});
+
const scheme = useColorScheme(colorSchemePreference);
+
const { did, loading: resolvingDid } = useDidResolution(
+
submitted ?? undefined,
+
);
+
const onSubmit = useCallback<React.FormEventHandler>((e) => {
+
e.preventDefault();
+
const rawValue = handleInputRef.current?.value;
+
const nextValue = rawValue?.trim();
+
if (!nextValue) return;
+
if (handleInputRef.current) {
+
handleInputRef.current.value = nextValue;
+
}
+
setSubmitted(nextValue);
+
}, []);
+
useEffect(() => {
+
if (typeof window === "undefined") return;
+
try {
+
window.localStorage.setItem(
+
COLOR_SCHEME_STORAGE_KEY,
+
colorSchemePreference,
+
);
+
} catch {
+
/* ignore */
+
}
+
}, [colorSchemePreference]);
+
useEffect(() => {
+
if (typeof document === "undefined") return;
+
const root = document.documentElement;
+
const body = document.body;
+
const prevScheme = root.dataset.colorScheme;
+
const prevBg = body.style.backgroundColor;
+
const prevColor = body.style.color;
+
root.dataset.colorScheme = scheme;
+
body.style.backgroundColor = scheme === "dark" ? "#020617" : "#f8fafc";
+
body.style.color = scheme === "dark" ? "#e2e8f0" : "#0f172a";
+
return () => {
+
root.dataset.colorScheme = prevScheme ?? "";
+
body.style.backgroundColor = prevBg;
+
body.style.color = prevColor;
+
};
+
}, [scheme]);
+
const showHandle =
+
submitted && !submitted.startsWith("did:") ? submitted : undefined;
+
const mutedTextColor = useMemo(
+
() => (scheme === "dark" ? "#94a3b8" : "#555"),
+
[scheme],
+
);
+
const panelStyle = useMemo<React.CSSProperties>(
+
() => ({
+
display: "flex",
+
flexDirection: "column",
+
gap: 8,
+
padding: 10,
+
borderRadius: 12,
+
borderColor: scheme === "dark" ? "#1e293b" : "#e2e8f0",
+
}),
+
[scheme],
+
);
+
const baseTextColor = useMemo(
+
() => (scheme === "dark" ? "#e2e8f0" : "#0f172a"),
+
[scheme],
+
);
+
const gistPanelStyle = useMemo<React.CSSProperties>(
+
() => ({
+
...panelStyle,
+
padding: 0,
+
border: "none",
+
background: "transparent",
+
backdropFilter: "none",
+
marginTop: 32,
+
}),
+
[panelStyle],
+
);
+
const leafletPanelStyle = useMemo<React.CSSProperties>(
+
() => ({
+
...panelStyle,
+
padding: 0,
+
border: "none",
+
background: "transparent",
+
backdropFilter: "none",
+
marginTop: 32,
+
alignItems: "center",
+
}),
+
[panelStyle],
+
);
+
const primaryGridStyle = useMemo<React.CSSProperties>(
+
() => ({
+
display: "grid",
+
gap: 32,
+
gridTemplateColumns: "repeat(auto-fit, minmax(320px, 1fr))",
+
}),
+
[],
+
);
+
const columnStackStyle = useMemo<React.CSSProperties>(
+
() => ({
+
display: "flex",
+
flexDirection: "column",
+
gap: 32,
+
}),
+
[],
+
);
+
const codeBlockStyle = useMemo<React.CSSProperties>(
+
() => ({
+
...codeBlockBase,
+
background: scheme === "dark" ? "#0b1120" : "#f1f5f9",
+
border: `1px solid ${scheme === "dark" ? "#1e293b" : "#e2e8f0"}`,
+
}),
+
[scheme],
+
);
+
const codeTextStyle = useMemo<React.CSSProperties>(
+
() => ({
+
margin: 0,
+
display: "block",
+
fontFamily: codeBlockBase.fontFamily,
+
fontSize: 12,
+
lineHeight: 1.6,
+
whiteSpace: "pre",
+
}),
+
[],
+
);
+
const basicCodeRef = useRef<HTMLElement | null>(null);
+
const customCodeRef = useRef<HTMLElement | null>(null);
+
// Latest Bluesky post
+
const {
+
rkey: latestPostRkey,
+
loading: loadingLatestPost,
+
empty: noPosts,
+
error: latestPostError,
+
} = useLatestRecord<unknown>(did, BLUESKY_POST_COLLECTION);
+
const quoteSampleDid = "did:plc:ttdrpj45ibqunmfhdsb4zdwq";
+
const quoteSampleRkey = "3m2prlq6xxc2v";
+
return (
+
<div
+
style={{
+
display: "flex",
+
flexDirection: "column",
+
gap: 20,
+
color: baseTextColor,
+
}}
+
>
+
<div
+
style={{
+
display: "flex",
+
flexWrap: "wrap",
+
gap: 12,
+
alignItems: "center",
+
justifyContent: "space-between",
+
}}
+
>
+
<form
+
onSubmit={onSubmit}
+
style={{
+
display: "flex",
+
gap: 8,
+
flexWrap: "wrap",
+
flex: "1 1 320px",
+
}}
+
>
+
<input
+
placeholder="Handle or DID (e.g. alice.bsky.social or did:plc:...)"
+
ref={handleInputRef}
+
style={{
+
flex: "1 1 260px",
+
padding: "6px 8px",
+
borderRadius: 8,
+
border: "1px solid",
+
borderColor:
+
scheme === "dark" ? "#1e293b" : "#cbd5f5",
+
background: scheme === "dark" ? "#0b1120" : "#fff",
+
color: scheme === "dark" ? "#e2e8f0" : "#0f172a",
+
}}
+
/>
+
<button
+
type="submit"
+
style={{
+
padding: "6px 16px",
+
borderRadius: 8,
+
border: "none",
+
background: "#2563eb",
+
color: "#fff",
+
cursor: "pointer",
+
}}
+
>
+
Load
+
</button>
+
</form>
+
<ColorSchemeToggle
+
value={colorSchemePreference}
+
onChange={setColorSchemePreference}
+
scheme={scheme}
+
/>
+
</div>
+
{!submitted && (
+
<p style={{ color: mutedTextColor }}>
+
Enter a handle to fetch your profile, latest Bluesky post, a
+
Tangled string, and a Leaflet document.
+
</p>
+
)}
+
{submitted && resolvingDid && (
+
<p style={{ color: mutedTextColor }}>Resolving DID…</p>
+
)}
+
{did && (
+
<>
+
<div style={primaryGridStyle}>
+
<div style={columnStackStyle}>
+
<section style={panelStyle}>
+
<h3 style={sectionHeaderStyle}>Profile</h3>
+
<BlueskyProfile
+
did={did}
+
handle={showHandle}
+
colorScheme={colorSchemePreference}
+
/>
+
</section>
+
<section style={panelStyle}>
+
<h3 style={sectionHeaderStyle}>Recent Posts</h3>
+
<BlueskyPostList
+
did={did}
+
colorScheme={colorSchemePreference}
+
/>
+
</section>
+
</div>
+
<div style={columnStackStyle}>
+
<section style={panelStyle}>
+
<h3 style={sectionHeaderStyle}>
+
Latest Bluesky Post
+
</h3>
+
{loadingLatestPost && (
+
<div style={loadingBox}>
+
Loading latest post…
+
</div>
+
)}
+
{latestPostError && (
+
<div style={errorBox}>
+
Failed to load latest post.
+
</div>
+
)}
+
{noPosts && (
+
<div
+
style={{
+
...infoBox,
+
color: mutedTextColor,
+
}}
+
>
+
No posts found.
+
</div>
+
)}
+
{!loadingLatestPost && latestPostRkey && (
+
<BlueskyPost
+
did={did}
+
rkey={latestPostRkey}
+
colorScheme={colorSchemePreference}
+
/>
+
)}
+
</section>
+
<section style={panelStyle}>
+
<h3 style={sectionHeaderStyle}>
+
Quote Post Demo
+
</h3>
+
<BlueskyQuotePost
+
did={quoteSampleDid}
+
rkey={quoteSampleRkey}
+
colorScheme={colorSchemePreference}
+
/>
+
</section>
+
</div>
+
</div>
+
<section style={gistPanelStyle}>
+
<h3 style={sectionHeaderStyle}>A Tangled String</h3>
+
<TangledString
+
did="nekomimi.pet"
+
rkey="3m2p4gjptg522"
+
colorScheme={colorSchemePreference}
+
/>
+
</section>
+
<section style={leafletPanelStyle}>
+
<h3 style={sectionHeaderStyle}>A Leaflet Document.</h3>
+
<div
+
style={{
+
width: "100%",
+
display: "flex",
+
justifyContent: "center",
+
}}
+
>
+
<LeafletDocument
+
did={"did:plc:ttdrpj45ibqunmfhdsb4zdwq"}
+
rkey={"3m2seagm2222c"}
+
colorScheme={colorSchemePreference}
+
/>
+
</div>
+
</section>
+
</>
+
)}
+
<section style={{ ...panelStyle, marginTop: 32 }}>
+
<h3 style={sectionHeaderStyle}>Build your own component</h3>
+
<p style={{ color: mutedTextColor, margin: "4px 0 8px" }}>
+
Wrap your app with the provider once and drop the ready-made
+
components wherever you need them.
+
</p>
+
<pre style={codeBlockStyle}>
+
<code
+
ref={basicCodeRef}
+
className="language-tsx"
+
style={codeTextStyle}
+
>
+
{basicUsageSnippet}
+
</code>
+
</pre>
+
<p style={{ color: mutedTextColor, margin: "16px 0 8px" }}>
+
Need to make your own component? Compose your own renderer
+
with the hooks and utilities that ship with the library.
+
</p>
+
<pre style={codeBlockStyle}>
+
<code
+
ref={customCodeRef}
+
className="language-tsx"
+
style={codeTextStyle}
+
>
+
{customComponentSnippet}
+
</code>
+
</pre>
+
{did && (
+
<div
+
style={{
+
marginTop: 16,
+
display: "flex",
+
flexDirection: "column",
+
gap: 12,
+
}}
+
>
+
<p style={{ color: mutedTextColor, margin: 0 }}>
+
Live example with your handle:
+
</p>
+
<LatestPostSummary
+
did={did}
+
handle={showHandle}
+
colorScheme={colorSchemePreference}
+
/>
+
</div>
+
)}
+
</section>
+
</div>
+
);
};
+
const LatestPostSummary: React.FC<{
+
did: string;
+
handle?: string;
+
colorScheme: ColorSchemePreference;
+
}> = ({ did, colorScheme }) => {
+
const { record, rkey, loading, error } = useLatestRecord<FeedPostRecord>(
+
did,
+
BLUESKY_POST_COLLECTION,
+
);
+
const scheme = useColorScheme(colorScheme);
+
const palette =
+
scheme === "dark"
+
? latestSummaryPalette.dark
+
: latestSummaryPalette.light;
+
if (loading) return <div style={palette.muted}>Loading summary…</div>;
+
if (error)
+
return <div style={palette.error}>Failed to load the latest post.</div>;
+
if (!rkey) return <div style={palette.muted}>No posts published yet.</div>;
+
const atProtoProps = record
+
? { record }
+
: { did, collection: "app.bsky.feed.post", rkey };
+
return (
+
<AtProtoRecord<FeedPostRecord>
+
{...atProtoProps}
+
renderer={({ record: resolvedRecord }) => (
+
<article data-color-scheme={scheme}>
+
<strong>{resolvedRecord?.text ?? "Empty post"}</strong>
+
</article>
+
)}
+
/>
+
);
};
+
const sectionHeaderStyle: React.CSSProperties = {
+
margin: "4px 0",
+
fontSize: 16,
+
};
const loadingBox: React.CSSProperties = { padding: 8 };
+
const errorBox: React.CSSProperties = { padding: 8, color: "crimson" };
+
const infoBox: React.CSSProperties = { padding: 8, color: "#555" };
const latestSummaryPalette = {
+
light: {
+
card: {
+
border: "1px solid #e2e8f0",
+
background: "#ffffff",
+
borderRadius: 12,
+
padding: 12,
+
display: "flex",
+
flexDirection: "column",
+
gap: 8,
+
} satisfies React.CSSProperties,
+
header: {
+
display: "flex",
+
alignItems: "baseline",
+
justifyContent: "space-between",
+
gap: 12,
+
color: "#0f172a",
+
} satisfies React.CSSProperties,
+
time: {
+
fontSize: 12,
+
color: "#64748b",
+
} satisfies React.CSSProperties,
+
text: {
+
margin: 0,
+
color: "#1f2937",
+
whiteSpace: "pre-wrap",
+
} satisfies React.CSSProperties,
+
link: {
+
color: "#2563eb",
+
fontWeight: 600,
+
fontSize: 12,
+
textDecoration: "none",
+
} satisfies React.CSSProperties,
+
muted: {
+
color: "#64748b",
+
} satisfies React.CSSProperties,
+
error: {
+
color: "crimson",
+
} satisfies React.CSSProperties,
+
},
+
dark: {
+
card: {
+
border: "1px solid #1e293b",
+
background: "#0f172a",
+
borderRadius: 12,
+
padding: 12,
+
display: "flex",
+
flexDirection: "column",
+
gap: 8,
+
} satisfies React.CSSProperties,
+
header: {
+
display: "flex",
+
alignItems: "baseline",
+
justifyContent: "space-between",
+
gap: 12,
+
color: "#e2e8f0",
+
} satisfies React.CSSProperties,
+
time: {
+
fontSize: 12,
+
color: "#cbd5f5",
+
} satisfies React.CSSProperties,
+
text: {
+
margin: 0,
+
color: "#e2e8f0",
+
whiteSpace: "pre-wrap",
+
} satisfies React.CSSProperties,
+
link: {
+
color: "#38bdf8",
+
fontWeight: 600,
+
fontSize: 12,
+
textDecoration: "none",
+
} satisfies React.CSSProperties,
+
muted: {
+
color: "#94a3b8",
+
} satisfies React.CSSProperties,
+
error: {
+
color: "#f472b6",
+
} satisfies React.CSSProperties,
+
},
} as const;
export const App: React.FC = () => {
+
return (
+
<AtProtoProvider>
+
<div
+
style={{
+
maxWidth: 860,
+
margin: "40px auto",
+
padding: "0 20px",
+
fontFamily: "system-ui, sans-serif",
+
}}
+
>
+
<h1 style={{ marginTop: 0 }}>atproto-ui Demo</h1>
+
<p style={{ lineHeight: 1.4 }}>
+
A component library for rendering common AT Protocol records
+
for applications such as Bluesky and Tangled.
+
</p>
+
<hr style={{ margin: "32px 0" }} />
+
<FullDemo />
+
</div>
+
</AtProtoProvider>
+
);
};
export default App;
+5 -5
src/main.tsx
···
-
import { createRoot } from 'react-dom/client';
-
import App from './App';
-
const el = document.getElementById('root');
if (el) {
-
const root = createRoot(el);
-
root.render(<App />);
}
···
+
import { createRoot } from "react-dom/client";
+
import App from "./App";
+
const el = document.getElementById("root");
if (el) {
+
const root = createRoot(el);
+
root.render(<App />);
}