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;