A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import React from 'react';
2import { AtProtoRecord } from '../core/AtProtoRecord';
3import { BlueskyPostRenderer } from '../renderers/BlueskyPostRenderer';
4import type { FeedPostRecord, ProfileRecord } from '../types/bluesky';
5import { useDidResolution } from '../hooks/useDidResolution';
6import { useAtProtoRecord } from '../hooks/useAtProtoRecord';
7import { useBlob } from '../hooks/useBlob';
8import { BLUESKY_PROFILE_COLLECTION } from './BlueskyProfile';
9import { getAvatarCid } from '../utils/profile';
10import { formatDidForLabel } from '../utils/at-uri';
11
12/**
13 * Props for rendering a single Bluesky post with optional customization hooks.
14 */
15export interface BlueskyPostProps {
16 /**
17 * Decentralized identifier for the repository that owns the post.
18 */
19 did: string;
20 /**
21 * Record key identifying the specific post within the collection.
22 */
23 rkey: string;
24 /**
25 * Custom renderer component that receives resolved post data and status flags.
26 */
27 renderer?: React.ComponentType<BlueskyPostRendererInjectedProps>;
28 /**
29 * React node shown while the post query has not yet produced data or an error.
30 */
31 fallback?: React.ReactNode;
32 /**
33 * React node displayed while the post fetch is actively loading.
34 */
35 loadingIndicator?: React.ReactNode;
36 /**
37 * Preferred color scheme to pass through to renderers.
38 */
39 colorScheme?: 'light' | 'dark' | 'system';
40 /**
41 * Whether the default renderer should show the Bluesky icon.
42 * Defaults to `true`.
43 */
44 showIcon?: boolean;
45 /**
46 * Placement strategy for the icon when it is rendered.
47 * Defaults to `'timestamp'`.
48 */
49 iconPlacement?: 'cardBottomRight' | 'timestamp' | 'linkInline';
50}
51
52/**
53 * Values injected by `BlueskyPost` into a downstream renderer component.
54 */
55export type BlueskyPostRendererInjectedProps = {
56 /**
57 * Resolved record payload for the post.
58 */
59 record: FeedPostRecord;
60 /**
61 * `true` while network operations are in-flight.
62 */
63 loading: boolean;
64 /**
65 * Error encountered during loading, if any.
66 */
67 error?: Error;
68 /**
69 * The author's public handle derived from the DID.
70 */
71 authorHandle: string;
72 /**
73 * The DID that owns the post record.
74 */
75 authorDid: string;
76 /**
77 * Resolved URL for the author's avatar blob, if available.
78 */
79 avatarUrl?: string;
80 /**
81 * Preferred color scheme bubbled down to children.
82 */
83 colorScheme?: 'light' | 'dark' | 'system';
84 /**
85 * Placement strategy for the Bluesky icon.
86 */
87 iconPlacement?: 'cardBottomRight' | 'timestamp' | 'linkInline';
88 /**
89 * Controls whether the icon should render at all.
90 */
91 showIcon?: boolean;
92 /**
93 * Fully qualified AT URI of the post, when resolvable.
94 */
95 atUri?: string;
96 /**
97 * Optional override for the rendered embed contents.
98 */
99 embed?: React.ReactNode;
100};
101
102/** NSID for the canonical Bluesky feed post collection. */
103export const BLUESKY_POST_COLLECTION = 'app.bsky.feed.post';
104
105/**
106 * Fetches a Bluesky feed post, resolves metadata such as author handle and avatar,
107 * and renders it via a customizable renderer component.
108 *
109 * @param did - DID of the repository that stores the post.
110 * @param rkey - Record key for the post within the feed collection.
111 * @param renderer - Optional renderer component to override the default.
112 * @param fallback - Node rendered before the first fetch attempt resolves.
113 * @param loadingIndicator - Node rendered while the post is loading.
114 * @param colorScheme - Preferred color scheme forwarded to downstream components.
115 * @param showIcon - Controls whether the Bluesky icon should render alongside the post. Defaults to `true`.
116 * @param iconPlacement - Determines where the icon is positioned in the rendered post. Defaults to `'timestamp'`.
117 * @returns A component that renders loading/fallback states and the resolved post.
118 */
119export const BlueskyPost: React.FC<BlueskyPostProps> = ({ did: handleOrDid, rkey, renderer, fallback, loadingIndicator, colorScheme, showIcon = true, iconPlacement = 'timestamp' }) => {
120 const { did: resolvedDid, handle, loading: resolvingIdentity, error: resolutionError } = useDidResolution(handleOrDid);
121 const repoIdentifier = resolvedDid ?? handleOrDid;
122 const { record: profile } = useAtProtoRecord<ProfileRecord>({ did: repoIdentifier, collection: BLUESKY_PROFILE_COLLECTION, rkey: 'self' });
123 const avatarCid = getAvatarCid(profile);
124 const { url: avatarUrl } = useBlob(repoIdentifier, avatarCid);
125
126 const Comp: React.ComponentType<BlueskyPostRendererInjectedProps> = renderer ?? ((props) => <BlueskyPostRenderer {...props} />);
127
128 const displayHandle = handle ?? (handleOrDid.startsWith('did:') ? undefined : handleOrDid);
129 const authorHandle = displayHandle ?? formatDidForLabel(resolvedDid ?? handleOrDid);
130 if (!displayHandle && resolvingIdentity) {
131 return <div style={{ padding: 8 }}>Resolving handle…</div>;
132 }
133 if (!displayHandle && resolutionError) {
134 return <div style={{ padding: 8, color: 'crimson' }}>Could not resolve handle.</div>;
135 }
136
137 const atUri = resolvedDid ? `at://${resolvedDid}/${BLUESKY_POST_COLLECTION}/${rkey}` : undefined;
138
139 const Wrapped: React.FC<{ record: FeedPostRecord; loading: boolean; error?: Error }> = (props) => (
140 <Comp
141 {...props}
142 authorHandle={authorHandle}
143 authorDid={repoIdentifier}
144 avatarUrl={avatarUrl}
145 colorScheme={colorScheme}
146 iconPlacement={iconPlacement}
147 showIcon={showIcon}
148 atUri={atUri}
149 />
150 );
151 return (
152 <AtProtoRecord<FeedPostRecord>
153 did={repoIdentifier}
154 collection={BLUESKY_POST_COLLECTION}
155 rkey={rkey}
156 renderer={Wrapped}
157 fallback={fallback}
158 loadingIndicator={loadingIndicator}
159 />
160 );
161};
162
163export default BlueskyPost;