A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import React, { useMemo } 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"; 11import type { BlobWithCdn } from "../hooks/useBlueskyAppview"; 12 13/** 14 * Props for rendering a single Bluesky post with optional customization hooks. 15 */ 16export interface BlueskyPostProps { 17 /** 18 * Decentralized identifier for the repository that owns the post. 19 */ 20 did: string; 21 /** 22 * Record key identifying the specific post within the collection. 23 */ 24 rkey: string; 25 /** 26 * Prefetched post record. When provided, skips fetching the post from the network. 27 * Note: Profile and avatar data will still be fetched unless a custom renderer is used. 28 */ 29 record?: FeedPostRecord; 30 /** 31 * Custom renderer component that receives resolved post data and status flags. 32 */ 33 renderer?: React.ComponentType<BlueskyPostRendererInjectedProps>; 34 /** 35 * React node shown while the post query has not yet produced data or an error. 36 */ 37 fallback?: React.ReactNode; 38 /** 39 * React node displayed while the post fetch is actively loading. 40 */ 41 loadingIndicator?: React.ReactNode; 42 /** 43 * Preferred color scheme to pass through to renderers. 44 */ 45 colorScheme?: "light" | "dark" | "system"; 46 /** 47 * Whether the default renderer should show the Bluesky icon. 48 * Defaults to `true`. 49 */ 50 showIcon?: boolean; 51 /** 52 * Placement strategy for the icon when it is rendered. 53 * Defaults to `'timestamp'`. 54 */ 55 iconPlacement?: "cardBottomRight" | "timestamp" | "linkInline"; 56} 57 58/** 59 * Values injected by `BlueskyPost` into a downstream renderer component. 60 */ 61export type BlueskyPostRendererInjectedProps = { 62 /** 63 * Resolved record payload for the post. 64 */ 65 record: FeedPostRecord; 66 /** 67 * `true` while network operations are in-flight. 68 */ 69 loading: boolean; 70 /** 71 * Error encountered during loading, if any. 72 */ 73 error?: Error; 74 /** 75 * The author's public handle derived from the DID. 76 */ 77 authorHandle: string; 78 /** 79 * The DID that owns the post record. 80 */ 81 authorDid: string; 82 /** 83 * Resolved URL for the author's avatar blob, if available. 84 */ 85 avatarUrl?: string; 86 /** 87 * Preferred color scheme bubbled down to children. 88 */ 89 colorScheme?: "light" | "dark" | "system"; 90 /** 91 * Placement strategy for the Bluesky icon. 92 */ 93 iconPlacement?: "cardBottomRight" | "timestamp" | "linkInline"; 94 /** 95 * Controls whether the icon should render at all. 96 */ 97 showIcon?: boolean; 98 /** 99 * Fully qualified AT URI of the post, when resolvable. 100 */ 101 atUri?: string; 102 /** 103 * Optional override for the rendered embed contents. 104 */ 105 embed?: React.ReactNode; 106}; 107 108/** NSID for the canonical Bluesky feed post collection. */ 109export const BLUESKY_POST_COLLECTION = "app.bsky.feed.post"; 110 111/** 112 * Fetches a Bluesky feed post, resolves metadata such as author handle and avatar, 113 * and renders it via a customizable renderer component. 114 * 115 * @param did - DID of the repository that stores the post. 116 * @param rkey - Record key for the post within the feed collection. 117 * @param renderer - Optional renderer component to override the default. 118 * @param fallback - Node rendered before the first fetch attempt resolves. 119 * @param loadingIndicator - Node rendered while the post is loading. 120 * @param colorScheme - Preferred color scheme forwarded to downstream components. 121 * @param showIcon - Controls whether the Bluesky icon should render alongside the post. Defaults to `true`. 122 * @param iconPlacement - Determines where the icon is positioned in the rendered post. Defaults to `'timestamp'`. 123 * @returns A component that renders loading/fallback states and the resolved post. 124 */ 125export const BlueskyPost: React.FC<BlueskyPostProps> = ({ 126 did: handleOrDid, 127 rkey, 128 record, 129 renderer, 130 fallback, 131 loadingIndicator, 132 colorScheme, 133 showIcon = true, 134 iconPlacement = "timestamp", 135}) => { 136 const { 137 did: resolvedDid, 138 handle, 139 loading: resolvingIdentity, 140 error: resolutionError, 141 } = useDidResolution(handleOrDid); 142 const repoIdentifier = resolvedDid ?? handleOrDid; 143 const { record: profile } = useAtProtoRecord<ProfileRecord>({ 144 did: repoIdentifier, 145 collection: BLUESKY_PROFILE_COLLECTION, 146 rkey: "self", 147 }); 148 // Check if the avatar has a CDN URL from the appview (preferred) 149 const avatar = profile?.avatar; 150 const avatarCdnUrl = isBlobWithCdn(avatar) ? avatar.cdnUrl : undefined; 151 const avatarCid = !avatarCdnUrl ? getAvatarCid(profile) : undefined; 152 153 const Comp: React.ComponentType<BlueskyPostRendererInjectedProps> = useMemo( 154 () => renderer ?? ((props) => <BlueskyPostRenderer {...props} />), 155 [renderer] 156 ); 157 158 const displayHandle = 159 handle ?? (handleOrDid.startsWith("did:") ? undefined : handleOrDid); 160 const authorHandle = 161 displayHandle ?? formatDidForLabel(resolvedDid ?? handleOrDid); 162 const atUri = resolvedDid 163 ? `at://${resolvedDid}/${BLUESKY_POST_COLLECTION}/${rkey}` 164 : undefined; 165 166 const Wrapped = useMemo(() => { 167 const WrappedComponent: React.FC<{ 168 record: FeedPostRecord; 169 loading: boolean; 170 error?: Error; 171 }> = (props) => { 172 const { url: avatarUrlFromBlob } = useBlob(repoIdentifier, avatarCid); 173 // Use CDN URL from appview if available, otherwise use blob URL 174 const avatarUrl = avatarCdnUrl || avatarUrlFromBlob; 175 return ( 176 <Comp 177 {...props} 178 authorHandle={authorHandle} 179 authorDid={repoIdentifier} 180 avatarUrl={avatarUrl} 181 colorScheme={colorScheme} 182 iconPlacement={iconPlacement} 183 showIcon={showIcon} 184 atUri={atUri} 185 /> 186 ); 187 }; 188 WrappedComponent.displayName = "BlueskyPostWrappedRenderer"; 189 return WrappedComponent; 190 }, [ 191 Comp, 192 repoIdentifier, 193 avatarCid, 194 avatarCdnUrl, 195 authorHandle, 196 colorScheme, 197 iconPlacement, 198 showIcon, 199 atUri, 200 ]); 201 202 if (!displayHandle && resolvingIdentity) { 203 return <div style={{ padding: 8 }}>Resolving handle</div>; 204 } 205 if (!displayHandle && resolutionError) { 206 return ( 207 <div style={{ padding: 8, color: "crimson" }}> 208 Could not resolve handle. 209 </div> 210 ); 211 } 212 213 214 if (record !== undefined) { 215 return ( 216 <AtProtoRecord<FeedPostRecord> 217 record={record} 218 renderer={Wrapped} 219 fallback={fallback} 220 loadingIndicator={loadingIndicator} 221 /> 222 ); 223 } 224 225 return ( 226 <AtProtoRecord<FeedPostRecord> 227 did={repoIdentifier} 228 collection={BLUESKY_POST_COLLECTION} 229 rkey={rkey} 230 renderer={Wrapped} 231 fallback={fallback} 232 loadingIndicator={loadingIndicator} 233 /> 234 ); 235}; 236 237/** 238 * Type guard to check if a blob has a CDN URL from appview. 239 */ 240function isBlobWithCdn(value: unknown): value is BlobWithCdn { 241 if (typeof value !== "object" || value === null) return false; 242 const obj = value as Record<string, unknown>; 243 return ( 244 obj.$type === "blob" && 245 typeof obj.cdnUrl === "string" && 246 typeof obj.ref === "object" && 247 obj.ref !== null && 248 typeof (obj.ref as { $link?: unknown }).$link === "string" 249 ); 250} 251 252export default BlueskyPost;