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 { BlueskyProfileRenderer } from "../renderers/BlueskyProfileRenderer";
4import type { ProfileRecord } from "../types/bluesky";
5import { useBlob } from "../hooks/useBlob";
6import { getAvatarCid } from "../utils/profile";
7import { useDidResolution } from "../hooks/useDidResolution";
8import { formatDidForLabel } from "../utils/at-uri";
9import { isBlobWithCdn } from "../utils/blob";
10
11/**
12 * Props used to render a Bluesky actor profile record.
13 */
14export interface BlueskyProfileProps {
15 /**
16 * DID of the target actor whose profile should be loaded.
17 */
18 did: string;
19 /**
20 * Record key within the profile collection. Typically `'self'`.
21 * Optional when `record` is provided.
22 */
23 rkey?: string;
24 /**
25 * Prefetched profile record. When provided, skips fetching the profile from the network.
26 */
27 record?: ProfileRecord;
28 /**
29 * Optional renderer override for custom presentation.
30 */
31 renderer?: React.ComponentType<BlueskyProfileRendererInjectedProps>;
32 /**
33 * Fallback node shown before a request begins yielding data.
34 */
35 fallback?: React.ReactNode;
36 /**
37 * Loading indicator shown during in-flight fetches.
38 */
39 loadingIndicator?: React.ReactNode;
40 /**
41 * Pre-resolved handle to display when available externally.
42 */
43 handle?: string;
44 /**
45 * Preferred color scheme forwarded to renderer implementations.
46 */
47 colorScheme?: "light" | "dark" | "system";
48}
49
50/**
51 * Props injected into custom profile renderer implementations.
52 */
53export type BlueskyProfileRendererInjectedProps = {
54 /**
55 * Loaded profile record value.
56 */
57 record: ProfileRecord;
58 /**
59 * Indicates whether the record is currently being fetched.
60 */
61 loading: boolean;
62 /**
63 * Any error encountered while fetching the profile.
64 */
65 error?: Error;
66 /**
67 * DID associated with the profile.
68 */
69 did: string;
70 /**
71 * Human-readable handle for the DID, when known.
72 */
73 handle?: string;
74 /**
75 * Blob URL for the user's avatar, when available.
76 */
77 avatarUrl?: string;
78 /**
79 * Preferred color scheme for theming downstream components.
80 */
81 colorScheme?: "light" | "dark" | "system";
82};
83
84/** NSID for the canonical Bluesky profile collection. */
85export const BLUESKY_PROFILE_COLLECTION = "app.bsky.actor.profile";
86
87/**
88 * Fetches and renders a Bluesky actor profile, optionally injecting custom presentation
89 * and providing avatar resolution support.
90 *
91 * @param did - DID whose profile record should be fetched.
92 * @param rkey - Record key within the profile collection (default `'self'`).
93 * @param renderer - Optional component override for custom rendering.
94 * @param fallback - Node rendered prior to loading state initialization.
95 * @param loadingIndicator - Node rendered while the profile request is in-flight.
96 * @param handle - Optional pre-resolved handle to display.
97 * @param colorScheme - Preferred color scheme forwarded to the renderer.
98 * @returns A rendered profile component with loading/error states handled.
99 */
100export const BlueskyProfile: React.FC<BlueskyProfileProps> = ({
101 did: handleOrDid,
102 rkey = "self",
103 record,
104 renderer,
105 fallback,
106 loadingIndicator,
107 handle,
108 colorScheme,
109}) => {
110 const Component: React.ComponentType<BlueskyProfileRendererInjectedProps> =
111 renderer ?? ((props) => <BlueskyProfileRenderer {...props} />);
112 const { did, handle: resolvedHandle } = useDidResolution(handleOrDid);
113 const repoIdentifier = did ?? handleOrDid;
114 const effectiveHandle =
115 handle ??
116 resolvedHandle ??
117 (handleOrDid.startsWith("did:")
118 ? formatDidForLabel(repoIdentifier)
119 : handleOrDid);
120
121 const Wrapped: React.FC<{
122 record: ProfileRecord;
123 loading: boolean;
124 error?: Error;
125 }> = (props) => {
126 // Check if the avatar has a CDN URL from the appview (preferred)
127 const avatar = props.record?.avatar;
128 const avatarCdnUrl = isBlobWithCdn(avatar) ? avatar.cdnUrl : undefined;
129 const avatarCid = avatarCdnUrl ? undefined : getAvatarCid(props.record);
130 const { url: avatarUrlFromBlob } = useBlob(repoIdentifier, avatarCid);
131 const avatarUrl = avatarCdnUrl || avatarUrlFromBlob;
132
133 return (
134 <Component
135 {...props}
136 did={repoIdentifier}
137 handle={effectiveHandle}
138 avatarUrl={avatarUrl}
139 colorScheme={colorScheme}
140 />
141 );
142 };
143
144 if (record !== undefined) {
145 return (
146 <AtProtoRecord<ProfileRecord>
147 record={record}
148 renderer={Wrapped}
149 fallback={fallback}
150 loadingIndicator={loadingIndicator}
151 />
152 );
153 }
154
155 return (
156 <AtProtoRecord<ProfileRecord>
157 did={repoIdentifier}
158 collection={BLUESKY_PROFILE_COLLECTION}
159 rkey={rkey}
160 renderer={Wrapped}
161 fallback={fallback}
162 loadingIndicator={loadingIndicator}
163 />
164 );
165};
166
167export default BlueskyProfile;