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';
+
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>
-
);
+
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. |
+
| 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';
+
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');
+
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>;
+
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"
-
/>
-
);
+
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;
+
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';
+
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');
+
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>;
+
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>
-
)}
-
/>
-
);
+
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!
+
Contributions and ideas are welcome—feel free to open an issue or PR!
+36 -32
lib/components/BlueskyIcon.tsx
···
-
import React from 'react';
+
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;
+
/**
+
* 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 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';
+
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';
+
/**
+
* 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;
+
/**
+
* 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';
+
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);
+
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 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 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]);
+
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>;
-
}
+
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}
-
/>
-
);
+
return (
+
<AtProtoRecord<FeedPostRecord>
+
did={repoIdentifier}
+
collection={BLUESKY_POST_COLLECTION}
+
rkey={rkey}
+
renderer={Wrapped}
+
fallback={fallback}
+
loadingIndicator={loadingIndicator}
+
/>
+
);
};
-
export default BlueskyPost;
+
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';
+
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';
+
/**
+
* 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;
+
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 {
+
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]);
+
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>;
+
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>
-
);
+
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;
+
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);
+
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>
-
);
+
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:)?/, '');
+
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);
+
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 };
+
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
+
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'
-
}
+
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'
-
}
+
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;
+
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)}`;
+
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';
+
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';
+
/**
+
* 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';
+
/**
+
* 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';
+
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);
+
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}
-
/>
-
);
+
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;
+
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';
+
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';
+
/**
+
* 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]);
+
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}
-
/>
-
);
+
return (
+
<BlueskyPost
+
did={did}
+
rkey={rkey}
+
colorScheme={colorScheme}
+
renderer={Renderer}
+
fallback={fallback}
+
loadingIndicator={loadingIndicator}
+
showIcon={showIcon}
+
iconPlacement={iconPlacement}
+
/>
+
);
};
-
BlueskyQuotePostComponent.displayName = 'BlueskyQuotePost';
+
BlueskyQuotePostComponent.displayName = "BlueskyQuotePost";
-
export const BlueskyQuotePost: NamedExoticComponent<BlueskyQuotePostProps> = memo(BlueskyQuotePostComponent);
-
BlueskyQuotePost.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>
-
);
+
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
+
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';
+
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';
+
/**
+
* 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' }
+
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;
+
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>
-
);
+
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'
+
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'
+
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'
-
}
+
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'
-
}
+
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';
+
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;
+
/**
+
* 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';
+
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} />);
+
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}
-
/>
-
);
-
};
+
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}
-
/>
-
);
+
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)}`;
+
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';
+
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';
+
colorScheme?: "light" | "dark" | "system";
}
/**
···
/** Fetch error, if any. */
error?: Error;
/** Preferred color scheme for downstream components. */
-
colorScheme?: 'light' | 'dark' | 'system';
+
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';
+
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) => (
+
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';
+
import React from "react";
+
import { useAtProtoRecord } from "../hooks/useAtProtoRecord";
interface AtProtoRecordRenderProps<T> {
-
renderer?: React.ComponentType<{ record: T; loading: boolean; error?: Error }>;
+
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 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 {
+
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>({
+
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>;
+
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';
+
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;
+
/** 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;
+
/** 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) });
+
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;
+
useEffect(() => {
+
let cancelled = false;
-
const assignState = (next: Partial<AtProtoRecordState<T>>) => {
-
if (cancelled) return;
-
setState(prev => ({ ...prev, ...next }));
-
};
+
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 (!handleOrDid || !collection || !rkey) {
+
assignState({
+
loading: false,
+
record: undefined,
+
error: undefined,
+
});
+
return () => {
+
cancelled = true;
+
};
+
}
-
if (didError) {
-
assignState({ loading: false, error: didError });
-
return () => { cancelled = true; };
-
}
+
if (didError) {
+
assignState({ loading: false, error: didError });
+
return () => {
+
cancelled = true;
+
};
+
}
-
if (endpointError) {
-
assignState({ loading: false, error: endpointError });
-
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; };
-
}
+
if (resolvingDid || resolvingEndpoint || !did || !endpoint) {
+
assignState({ loading: true, error: undefined });
+
return () => {
+
cancelled = true;
+
};
+
}
-
assignState({ loading: true, error: undefined, record: undefined });
+
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 });
-
}
-
})();
+
(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 () => {
+
cancelled = true;
+
};
+
}, [
+
handleOrDid,
+
did,
+
endpoint,
+
collection,
+
rkey,
+
resolvingDid,
+
resolvingEndpoint,
+
didError,
+
endpointError,
+
]);
-
return state;
+
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';
+
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;
+
/** 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);
+
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(
+
() => () => {
+
if (objectUrlRef.current) {
+
URL.revokeObjectURL(objectUrlRef.current);
+
objectUrlRef.current = undefined;
+
}
+
},
+
[],
+
);
-
useEffect(() => {
-
let cancelled = false;
+
useEffect(() => {
+
let cancelled = false;
-
const clearObjectUrl = () => {
-
if (objectUrlRef.current) {
-
URL.revokeObjectURL(objectUrlRef.current);
-
objectUrlRef.current = undefined;
-
}
-
};
+
const clearObjectUrl = () => {
+
if (objectUrlRef.current) {
+
URL.revokeObjectURL(objectUrlRef.current);
+
objectUrlRef.current = undefined;
+
}
+
};
-
if (!handleOrDid || !cid) {
-
clearObjectUrl();
-
setState({ loading: false });
-
return () => {
-
cancelled = true;
-
};
-
}
+
if (!handleOrDid || !cid) {
+
clearObjectUrl();
+
setState({ loading: false });
+
return () => {
+
cancelled = true;
+
};
+
}
-
if (didError) {
-
clearObjectUrl();
-
setState({ loading: false, error: didError });
-
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 (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;
-
};
-
}
+
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;
-
};
-
}
+
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;
+
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 });
-
}
-
})();
+
(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 () => {
+
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;
+
return state;
}
+53 -44
lib/hooks/useBlueskyProfile.ts
···
-
import { useEffect, useState } from 'react';
-
import { usePdsEndpoint } from './usePdsEndpoint';
-
import { createAtprotoClient } from '../utils/atproto-client';
+
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;
+
/** 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>();
+
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]);
+
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 };
+
return { data, loading, error };
}
+27 -17
lib/hooks/useColorScheme.ts
···
-
import { useEffect, useState } from 'react';
+
import { useEffect, useState } from "react";
/**
* Possible user-facing color scheme preferences.
*/
-
export type ColorSchemePreference = 'light' | 'dark' | 'system';
+
export type ColorSchemePreference = "light" | "dark" | "system";
-
const MEDIA_QUERY = '(prefers-color-scheme: dark)';
+
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';
+
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';
+
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));
+
export function useColorScheme(
+
preference: ColorSchemePreference = "system",
+
): "light" | "dark" {
+
const [scheme, setScheme] = useState<"light" | "dark">(() =>
+
resolveScheme(preference),
+
);
useEffect(() => {
-
if (preference === 'light' || preference === 'dark') {
+
if (preference === "light" || preference === "dark") {
setScheme(preference);
return;
}
-
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
-
setScheme('light');
+
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');
+
setScheme(event.matches ? "dark" : "light");
};
update(media);
-
if (typeof media.addEventListener === 'function') {
-
media.addEventListener('change', update);
-
return () => media.removeEventListener('change', update);
+
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';
+
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; };
+
return () => {
+
cancelled = true;
+
};
}
-
const isDid = normalizedInput.startsWith('did:');
-
const normalizedHandle = !isDid ? normalizedInput.toLowerCase() : undefined;
+
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);
+
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);
+
const needsDocResolution =
+
isDid && (!cached?.doc || cached.handle === undefined);
if (!needsHandleResolution && !needsDocResolution) {
setLoading(false);
-
return () => { cancelled = true; };
+
return () => {
+
cancelled = true;
+
};
}
setLoading(true);
···
try {
let snapshot = cached;
if (!isDid && normalizedHandle && needsHandleResolution) {
-
snapshot = await didCache.ensureHandle(resolver, normalizedHandle);
+
snapshot = await didCache.ensureHandle(
+
resolver,
+
normalizedHandle,
+
);
}
if (isDid) {
-
snapshot = await didCache.ensureDidDoc(resolver, normalizedInput);
+
snapshot = await didCache.ensureDidDoc(
+
resolver,
+
normalizedInput,
+
);
}
if (!cancelled) {
-
const resolvedDid = snapshot?.did ?? (isDid ? normalizedInput : undefined);
-
const resolvedHandle = snapshot?.handle ?? (!isDid ? normalizedHandle : undefined);
+
const resolvedDid =
+
snapshot?.did ?? (isDid ? normalizedInput : undefined);
+
const resolvedHandle =
+
snapshot?.handle ??
+
(!isDid ? normalizedHandle : undefined);
setDid(resolvedDid);
setHandle(resolvedHandle);
setError(undefined);
···
}
})();
-
return () => { cancelled = true; };
+
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';
+
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;
+
/** 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 });
+
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;
+
useEffect(() => {
+
let cancelled = false;
-
const assign = (next: Partial<LatestRecordState<T>>) => {
-
if (cancelled) return;
-
setState(prev => ({ ...prev, ...next }));
-
};
+
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 (!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 (didError) {
+
assign({ loading: false, error: didError, empty: false });
+
return () => {
+
cancelled = true;
+
};
+
}
-
if (endpointError) {
-
assign({ loading: false, error: endpointError, 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; };
-
}
+
if (resolvingDid || resolvingEndpoint || !did || !endpoint) {
+
assign({ loading: true, error: undefined });
+
return () => {
+
cancelled = true;
+
};
+
}
-
assign({ loading: true, error: undefined, empty: false });
+
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 });
-
}
-
})();
+
(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 () => {
+
cancelled = true;
+
};
+
}, [
+
handleOrDid,
+
did,
+
endpoint,
+
collection,
+
resolvingDid,
+
resolvingEndpoint,
+
didError,
+
endpointError,
+
]);
-
return state;
+
return state;
}
function extractRkey(uri: string): string | undefined {
-
if (!uri) return undefined;
-
const parts = uri.split('/');
-
return parts[parts.length - 1];
+
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';
+
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;
+
/** 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;
+
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;
+
/** 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;
+
/** 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';
+
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';
+
| "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;
+
$type?: string;
+
by?: {
+
handle?: string;
+
did?: string;
+
};
+
indexedAt?: string;
}
export interface ReplyParentInfo {
-
uri?: string;
-
author?: {
-
handle?: string;
-
did?: string;
-
};
+
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
+
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 {
+
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 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 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 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 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;
-
}
-
}
+
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 (!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
-
]);
+
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;
-
}
+
useEffect(() => {
+
if (!handleOrDid) {
+
identityRef.current = undefined;
+
resetState();
+
setLoading(false);
+
setError(undefined);
+
return;
+
}
-
if (didError) {
-
identityRef.current = undefined;
-
resetState();
-
setLoading(false);
-
setError(didError);
-
return;
-
}
+
if (didError) {
+
identityRef.current = undefined;
+
resetState();
+
setLoading(false);
+
setError(didError);
+
return;
+
}
-
if (endpointError) {
-
identityRef.current = undefined;
-
resetState();
-
setLoading(false);
-
setError(endpointError);
-
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 (resolvingDid || resolvingEndpoint || !identity) {
+
if (identityRef.current !== identity) {
+
identityRef.current = identity;
+
resetState();
+
}
+
setLoading(!!handleOrDid);
+
setError(undefined);
+
return;
+
}
-
if (identityRef.current !== identity) {
-
identityRef.current = identity;
-
resetState();
-
}
+
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]);
+
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 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 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 loadPrev = useCallback(() => {
+
if (pageIndex === 0) return;
+
setPageIndex(pageIndex - 1);
+
}, [pageIndex]);
-
const records = useMemo(() => currentPage?.records ?? [], [currentPage]);
+
const records = useMemo(() => currentPage?.records ?? [], [currentPage]);
-
const effectiveError = error ?? (endpointError as Error | undefined) ?? (didError as Error | undefined);
+
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]);
+
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)
-
};
+
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];
+
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';
+
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; };
+
return () => {
+
cancelled = true;
+
};
}
const cached = didCache.getByDid(did);
···
setEndpoint(cached.pdsEndpoint);
setError(undefined);
setLoading(false);
-
return () => { cancelled = true; };
+
return () => {
+
cancelled = true;
+
};
}
setEndpoint(undefined);
setLoading(true);
setError(undefined);
-
didCache.ensurePdsEndpoint(resolver, did)
-
.then(snapshot => {
+
didCache
+
.ensurePdsEndpoint(resolver, did)
+
.then((snapshot) => {
if (cancelled) return;
setEndpoint(snapshot.pdsEndpoint);
})
-
.catch(e => {
+
.catch((e) => {
if (cancelled) return;
setError(e as Error);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
-
return () => { cancelled = true; };
+
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';
+
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';
+
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';
+
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';
+
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';
+
export * from "./types/bluesky";
+
export * from "./types/leaflet";
// Utilities
-
export * from './utils/at-uri';
-
export * from './utils/atproto-client';
-
export * from './utils/profile';
+
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';
+
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);
+
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);
+
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() };
+
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>;
+
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');
+
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';
+
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;
+
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);
+
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>;
+
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 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 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 } : {})
-
};
+
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>
-
);
+
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'
-
}
+
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>>;
+
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 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;
+
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: "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 };
+
$type: "app.bsky.embed.recordWithMedia";
+
record?: unknown;
+
media?: { $type?: string };
};
interface ImagesEmbedProps {
-
embed: ImagesEmbedType;
-
did?: string;
-
scheme: 'light' | 'dark';
+
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>
-
);
+
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';
+
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;
+
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>
-
);
+
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
+
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
-
}
+
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;
+
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';
+
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;
+
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);
+
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>;
+
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;
+
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>
-
);
+
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
-
}
+
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>>;
+
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;
+
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 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';
+
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;
+
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;
+
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>;
+
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 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>
-
);
-
}
+
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>
-
);
+
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>
-
);
+
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;
+
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);
+
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} />;
-
}
+
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 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)
-
};
+
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>
-
))
-
);
+
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 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;
+
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>
-
);
+
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 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 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 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);
+
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>
-
);
+
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 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 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 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 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" />;
+
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 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;
+
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[];
+
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 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;
+
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;
+
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;
+
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 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 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>;
-
}
+
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
-
}
+
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)'
-
}
-
}
+
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
+
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
+
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
+
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';
+
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 }) => {
+
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 (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' });
+
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>
+
<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 }}>
+
<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>
+
<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',
+
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%'
+
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
+
padding: "10px 16px",
+
display: "flex",
+
justifyContent: "space-between",
+
alignItems: "center",
+
gap: 12,
},
headerRight: {
-
display: 'flex',
-
alignItems: 'center',
+
display: "flex",
+
alignItems: "center",
gap: 12,
-
flexWrap: 'wrap',
-
justifyContent: 'flex-end'
+
flexWrap: "wrap",
+
justifyContent: "flex-end",
},
filename: {
-
fontFamily: 'SFMono-Regular, ui-monospace, Menlo, Monaco, "Courier New", monospace',
+
fontFamily:
+
'SFMono-Regular, ui-monospace, Menlo, Monaco, "Courier New", monospace',
fontSize: 13,
-
wordBreak: 'break-all'
+
wordBreak: "break-all",
},
timestamp: {
-
fontSize: 12
+
fontSize: 12,
},
headerLink: {
fontSize: 12,
fontWeight: 600,
-
textDecoration: 'none'
+
textDecoration: "none",
},
description: {
-
padding: '10px 16px',
+
padding: "10px 16px",
fontSize: 13,
-
borderTop: '1px solid transparent'
+
borderTop: "1px solid transparent",
},
codeBlock: {
margin: 0,
-
padding: '16px',
+
padding: "16px",
fontSize: 13,
-
overflowX: 'auto',
-
borderTop: '1px solid transparent',
-
fontFamily: 'SFMono-Regular, ui-monospace, Menlo, Monaco, "Courier New", monospace'
-
}
+
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)'
+
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'
+
background: "#f6f8fa",
+
borderBottom: "1px solid #d0d7de",
},
headerRight: {},
filename: {
-
color: '#1f2328'
+
color: "#1f2328",
},
timestamp: {
-
color: '#57606a'
+
color: "#57606a",
},
headerLink: {
-
color: '#2563eb'
+
color: "#2563eb",
},
description: {
-
background: '#ffffff',
-
borderBottom: '1px solid #d0d7de',
-
borderTopColor: '#d0d7de',
-
color: '#1f2328'
+
background: "#ffffff",
+
borderBottom: "1px solid #d0d7de",
+
borderTopColor: "#d0d7de",
+
color: "#1f2328",
},
codeBlock: {
-
background: '#ffffff',
-
color: '#1f2328',
-
borderTopColor: '#d0d7de'
-
}
+
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'
+
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'
+
background: "#161b22",
+
borderBottom: "1px solid #30363d",
},
headerRight: {},
filename: {
-
color: '#c9d1d9'
+
color: "#c9d1d9",
},
timestamp: {
-
color: '#8b949e'
+
color: "#8b949e",
},
headerLink: {
-
color: '#58a6ff'
+
color: "#58a6ff",
},
description: {
-
background: '#161b22',
-
borderBottom: '1px solid #30363d',
-
borderTopColor: '#30363d',
-
color: '#c9d1d9'
+
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>>;
+
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';
+
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;
+
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[];
+
$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[];
+
$type?: "pub.leaflet.pages.linearDocument";
+
blocks?: LeafletLinearDocumentBlock[];
}
export type LeafletAlignmentValue =
-
| "#textAlignLeft"
-
| "#textAlignCenter"
-
| "#textAlignRight"
-
| "#textAlignJustify"
-
| "textAlignLeft"
-
| "textAlignCenter"
-
| "textAlignRight"
-
| "textAlignJustify";
+
| "#textAlignLeft"
+
| "#textAlignCenter"
+
| "#textAlignRight"
+
| "#textAlignJustify"
+
| "textAlignLeft"
+
| "textAlignCenter"
+
| "textAlignRight"
+
| "textAlignJustify";
export interface LeafletLinearDocumentBlock {
-
block: LeafletBlock;
-
alignment?: LeafletAlignmentValue;
+
block: LeafletBlock;
+
alignment?: LeafletAlignmentValue;
}
export type LeafletBlock =
-
| LeafletTextBlock
-
| LeafletHeaderBlock
-
| LeafletBlockquoteBlock
-
| LeafletImageBlock
-
| LeafletUnorderedListBlock
-
| LeafletWebsiteBlock
-
| LeafletIFrameBlock
-
| LeafletMathBlock
-
| LeafletCodeBlock
-
| LeafletHorizontalRuleBlock
-
| LeafletBskyPostBlock;
+
| LeafletTextBlock
+
| LeafletHeaderBlock
+
| LeafletBlockquoteBlock
+
| LeafletImageBlock
+
| LeafletUnorderedListBlock
+
| LeafletWebsiteBlock
+
| LeafletIFrameBlock
+
| LeafletMathBlock
+
| LeafletCodeBlock
+
| LeafletHorizontalRuleBlock
+
| LeafletBskyPostBlock;
export interface LeafletBaseTextBlock {
-
plaintext: string;
-
facets?: LeafletRichTextFacet[];
+
plaintext: string;
+
facets?: LeafletRichTextFacet[];
}
export interface LeafletTextBlock extends LeafletBaseTextBlock {
-
$type?: "pub.leaflet.blocks.text";
+
$type?: "pub.leaflet.blocks.text";
}
export interface LeafletHeaderBlock extends LeafletBaseTextBlock {
-
$type?: "pub.leaflet.blocks.header";
-
level?: number;
+
$type?: "pub.leaflet.blocks.header";
+
level?: number;
}
export interface LeafletBlockquoteBlock extends LeafletBaseTextBlock {
-
$type?: "pub.leaflet.blocks.blockquote";
+
$type?: "pub.leaflet.blocks.blockquote";
}
export interface LeafletImageBlock {
-
$type?: "pub.leaflet.blocks.image";
-
image: LeafletBlobRef;
-
alt?: string;
-
aspectRatio: {
-
width: number;
-
height: number;
-
};
+
$type?: "pub.leaflet.blocks.image";
+
image: LeafletBlobRef;
+
alt?: string;
+
aspectRatio: {
+
width: number;
+
height: number;
+
};
}
export interface LeafletUnorderedListBlock {
-
$type?: "pub.leaflet.blocks.unorderedList";
-
children: LeafletListItem[];
+
$type?: "pub.leaflet.blocks.unorderedList";
+
children: LeafletListItem[];
}
export interface LeafletListItem {
-
content: LeafletListContent;
-
children?: LeafletListItem[];
+
content: LeafletListContent;
+
children?: LeafletListItem[];
}
-
export type LeafletListContent = LeafletTextBlock | LeafletHeaderBlock | LeafletImageBlock;
+
export type LeafletListContent =
+
| LeafletTextBlock
+
| LeafletHeaderBlock
+
| LeafletImageBlock;
export interface LeafletWebsiteBlock {
-
$type?: "pub.leaflet.blocks.website";
-
src: string;
-
title?: string;
-
description?: string;
-
previewImage?: LeafletBlobRef;
+
$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;
+
$type?: "pub.leaflet.blocks.iframe";
+
url: string;
+
height?: number;
}
export interface LeafletMathBlock {
-
$type?: "pub.leaflet.blocks.math";
-
tex: string;
+
$type?: "pub.leaflet.blocks.math";
+
tex: string;
}
export interface LeafletCodeBlock {
-
$type?: "pub.leaflet.blocks.code";
-
plaintext: string;
-
language?: string;
-
syntaxHighlightingTheme?: string;
+
$type?: "pub.leaflet.blocks.code";
+
plaintext: string;
+
language?: string;
+
syntaxHighlightingTheme?: string;
}
export interface LeafletHorizontalRuleBlock {
-
$type?: "pub.leaflet.blocks.horizontalRule";
+
$type?: "pub.leaflet.blocks.horizontalRule";
}
export interface LeafletBskyPostBlock {
-
$type?: "pub.leaflet.blocks.bskyPost";
-
postRef: StrongRef;
+
$type?: "pub.leaflet.blocks.bskyPost";
+
postRef: StrongRef;
}
export interface LeafletRichTextFacet {
-
index: LeafletByteSlice;
-
features: LeafletRichTextFeature[];
+
index: LeafletByteSlice;
+
features: LeafletRichTextFeature[];
}
export interface LeafletByteSlice {
-
byteStart: number;
-
byteEnd: number;
+
byteStart: number;
+
byteEnd: number;
}
export type LeafletRichTextFeature =
-
| LeafletRichTextLinkFeature
-
| LeafletRichTextCodeFeature
-
| LeafletRichTextHighlightFeature
-
| LeafletRichTextUnderlineFeature
-
| LeafletRichTextStrikethroughFeature
-
| LeafletRichTextIdFeature
-
| LeafletRichTextBoldFeature
-
| LeafletRichTextItalicFeature;
+
| LeafletRichTextLinkFeature
+
| LeafletRichTextCodeFeature
+
| LeafletRichTextHighlightFeature
+
| LeafletRichTextUnderlineFeature
+
| LeafletRichTextStrikethroughFeature
+
| LeafletRichTextIdFeature
+
| LeafletRichTextBoldFeature
+
| LeafletRichTextItalicFeature;
export interface LeafletRichTextLinkFeature {
-
$type: "pub.leaflet.richtext.facet#link";
-
uri: string;
+
$type: "pub.leaflet.richtext.facet#link";
+
uri: string;
}
export interface LeafletRichTextCodeFeature {
-
$type: "pub.leaflet.richtext.facet#code";
+
$type: "pub.leaflet.richtext.facet#code";
}
export interface LeafletRichTextHighlightFeature {
-
$type: "pub.leaflet.richtext.facet#highlight";
+
$type: "pub.leaflet.richtext.facet#highlight";
}
export interface LeafletRichTextUnderlineFeature {
-
$type: "pub.leaflet.richtext.facet#underline";
+
$type: "pub.leaflet.richtext.facet#underline";
}
export interface LeafletRichTextStrikethroughFeature {
-
$type: "pub.leaflet.richtext.facet#strikethrough";
+
$type: "pub.leaflet.richtext.facet#strikethrough";
}
export interface LeafletRichTextIdFeature {
-
$type: "pub.leaflet.richtext.facet#id";
-
id?: string;
+
$type: "pub.leaflet.richtext.facet#id";
+
id?: string;
}
export interface LeafletRichTextBoldFeature {
-
$type: "pub.leaflet.richtext.facet#bold";
+
$type: "pub.leaflet.richtext.facet#bold";
}
export interface LeafletRichTextItalicFeature {
-
$type: "pub.leaflet.richtext.facet#italic";
+
$type: "pub.leaflet.richtext.facet#italic";
}
export interface LeafletBlobRef {
-
$type?: string;
-
ref?: {
-
$link?: string;
-
};
-
cid?: string;
-
mimeType?: string;
-
size?: number;
+
$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;
+
$type?: "pub.leaflet.publication";
+
name: string;
+
base_path?: string;
+
description?: string;
+
icon?: LeafletBlobRef;
+
theme?: LeafletTheme;
+
preferences?: LeafletPublicationPreferences;
}
export interface LeafletPublicationPreferences {
-
showInDiscover?: boolean;
-
showComments?: boolean;
+
showInDiscover?: boolean;
+
showComments?: boolean;
}
export interface LeafletTheme {
-
backgroundColor?: LeafletThemeColor;
-
backgroundImage?: LeafletThemeBackgroundImage;
-
primary?: LeafletThemeColor;
-
pageBackground?: LeafletThemeColor;
-
showPageBackground?: boolean;
-
accentBackground?: LeafletThemeColor;
-
accentText?: LeafletThemeColor;
+
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;
+
$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;
+
$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;
+
$type?: "pub.leaflet.theme.backgroundImage";
+
image: LeafletBlobRef;
+
width?: number;
+
repeat?: boolean;
}
-
export type LeafletInlineRenderable = LeafletTextBlock | LeafletHeaderBlock | LeafletBlockquoteBlock;
+
export type LeafletInlineRenderable =
+
| LeafletTextBlock
+
| LeafletHeaderBlock
+
| LeafletBlockquoteBlock;
+34 -27
lib/utils/at-uri.ts
···
export interface ParsedAtUri {
-
did: string;
-
collection: string;
-
rkey: string;
+
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 };
+
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}`;
+
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:)?/, '');
+
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 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 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';
+
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;
+
plcDirectory?: string;
+
identityService?: string;
+
fetch?: typeof fetch;
}
-
const DEFAULT_PLC = 'https://plc.directory';
-
const DEFAULT_IDENTITY_SERVICE = 'https://public.api.bsky.app';
+
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;
+
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 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;
+
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 });
-
}
+
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 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 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));
-
}
+
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;
-
}
-
}
+
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
+
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 };
+
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'];
+
export type AtprotoClient = Awaited<
+
ReturnType<typeof createAtprotoClient>
+
>["rpc"];
const SLINGSHOT_RETRY_PATHS = [
-
'/xrpc/com.atproto.repo.getRecord',
-
'/xrpc/com.atproto.identity.resolveHandle',
+
"/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 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);
+
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';
+
import type { DidDocument } from "@atcute/identity";
+
import { ServiceResolver } from "./atproto-client";
interface DidCacheEntry {
did: string;
···
pdsEndpoint?: string;
}
-
const toSnapshot = (entry: DidCacheEntry | undefined): DidCacheSnapshot | undefined => {
+
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 => {
+
const derivePdsEndpoint = (
+
doc: DidDocument | undefined,
+
): string | undefined => {
if (!doc?.service) return undefined;
-
const svc = doc.service.find(service => service.type === 'AtprotoPersonalDataServer');
+
const svc = doc.service.find(
+
(service) => service.type === "AtprotoPersonalDataServer",
+
);
if (!svc) return undefined;
-
const endpoint = typeof svc.serviceEndpoint === 'string' ? svc.serviceEndpoint : undefined;
+
const endpoint =
+
typeof svc.serviceEndpoint === "string"
+
? svc.serviceEndpoint
+
: undefined;
if (!endpoint) return undefined;
-
return endpoint.replace(/\/$/, '');
+
return endpoint.replace(/\/$/, "");
};
export class DidCache {
···
return toSnapshot(this.byDid.get(did));
}
-
memoize(entry: { did: string; handle?: string; doc?: DidDocument; pdsEndpoint?: string }): DidCacheSnapshot {
+
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 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 pdsEndpoint =
+
entry.pdsEndpoint ??
+
derivePdsEndpoint(doc) ??
+
existing?.pdsEndpoint;
const merged: DidCacheEntry = {
did,
···
return toSnapshot(merged) as DidCacheSnapshot;
}
-
ensureHandle(resolver: ServiceResolver, handle: string): Promise<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 }))
+
.then((did) => this.memoize({ did, handle: normalized }))
.finally(() => {
this.handlePromises.delete(normalized);
});
···
return promise;
}
-
ensureDidDoc(resolver: ServiceResolver, did: string): Promise<DidCacheSnapshot> {
+
ensureDidDoc(
+
resolver: ServiceResolver,
+
did: string,
+
): Promise<DidCacheSnapshot> {
const cached = this.getByDid(did);
-
if (cached?.doc && cached.handle !== undefined) return Promise.resolve(cached);
+
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;
+
.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> {
+
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);
+
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 {
+
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 => {
+
const wrapped = promise.then((blob) => {
this.set(did, cid, blob);
return blob;
});
+5 -3
lib/utils/profile.ts
···
-
import type { ProfileRecord } from '../types/bluesky';
+
import type { ProfileRecord } from "../types/bluesky";
interface LegacyBlobRef {
ref?: { $link?: string };
cid?: string;
}
-
export function getAvatarCid(record: ProfileRecord | undefined): string | undefined {
+
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;
+
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';
+
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 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
+
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);
-
}, []);
+
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 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]);
+
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 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);
+
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);
+
// 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';
+
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>
-
);
+
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;
+
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>;
+
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 };
+
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>
-
)}
-
/>
-
);
+
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 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 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
-
}
+
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>
-
);
+
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';
+
import { createRoot } from "react-dom/client";
+
import App from "./App";
-
const el = document.getElementById('root');
+
const el = document.getElementById("root");
if (el) {
-
const root = createRoot(el);
-
root.render(<App />);
+
const root = createRoot(el);
+
root.render(<App />);
}