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 type { BlobWithCdn } from "../hooks/useBlueskyAppview";
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 ? getAvatarCid(props.record) : undefined;
130 const { url: avatarUrlFromBlob } = useBlob(repoIdentifier, avatarCid);
131
132 // Use CDN URL from appview if available, otherwise use blob URL
133 const avatarUrl = avatarCdnUrl || avatarUrlFromBlob;
134
135 return (
136 <Component
137 {...props}
138 did={repoIdentifier}
139 handle={effectiveHandle}
140 avatarUrl={avatarUrl}
141 colorScheme={colorScheme}
142 />
143 );
144 };
145
146 if (record !== undefined) {
147 return (
148 <AtProtoRecord<ProfileRecord>
149 record={record}
150 renderer={Wrapped}
151 fallback={fallback}
152 loadingIndicator={loadingIndicator}
153 />
154 );
155 }
156
157 return (
158 <AtProtoRecord<ProfileRecord>
159 did={repoIdentifier}
160 collection={BLUESKY_PROFILE_COLLECTION}
161 rkey={rkey}
162 renderer={Wrapped}
163 fallback={fallback}
164 loadingIndicator={loadingIndicator}
165 />
166 );
167};
168
169/**
170 * Type guard to check if a blob has a CDN URL from appview.
171 */
172function isBlobWithCdn(value: unknown): value is BlobWithCdn {
173 if (typeof value !== "object" || value === null) return false;
174 const obj = value as Record<string, unknown>;
175 return (
176 obj.$type === "blob" &&
177 typeof obj.cdnUrl === "string" &&
178 typeof obj.ref === "object" &&
179 obj.ref !== null &&
180 typeof (obj.ref as { $link?: unknown }).$link === "string"
181 );
182}
183
184export default BlueskyProfile;