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 { isBlobWithCdn } from "../utils/blob"; 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 /** 44 * Whether the default renderer should show the Bluesky icon. 45 * Defaults to `true`. 46 */ 47 showIcon?: boolean; 48 /** 49 * Placement strategy for the icon when it is rendered. 50 * Defaults to `'timestamp'`. 51 */ 52 iconPlacement?: "cardBottomRight" | "timestamp" | "linkInline"; 53} 54 55/** 56 * Values injected by `BlueskyPost` into a downstream renderer component. 57 */ 58export type BlueskyPostRendererInjectedProps = { 59 /** 60 * Resolved record payload for the post. 61 */ 62 record: FeedPostRecord; 63 /** 64 * `true` while network operations are in-flight. 65 */ 66 loading: boolean; 67 /** 68 * Error encountered during loading, if any. 69 */ 70 error?: Error; 71 /** 72 * The author's public handle derived from the DID. 73 */ 74 authorHandle: string; 75 /** 76 * The DID that owns the post record. 77 */ 78 authorDid: string; 79 /** 80 * Resolved URL for the author's avatar blob, if available. 81 */ 82 avatarUrl?: string; 83 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 showIcon - Controls whether the Bluesky icon should render alongside the post. Defaults to `true`. 115 * @param iconPlacement - Determines where the icon is positioned in the rendered post. Defaults to `'timestamp'`. 116 * @returns A component that renders loading/fallback states and the resolved post. 117 */ 118export const BlueskyPost: React.FC<BlueskyPostProps> = React.memo(({ 119 did: handleOrDid, 120 rkey, 121 record, 122 renderer, 123 fallback, 124 loadingIndicator, 125 showIcon = true, 126 iconPlacement = "timestamp", 127}) => { 128 const { 129 did: resolvedDid, 130 handle, 131 loading: resolvingIdentity, 132 error: resolutionError, 133 } = useDidResolution(handleOrDid); 134 const repoIdentifier = resolvedDid ?? handleOrDid; 135 const { record: profile } = useAtProtoRecord<ProfileRecord>({ 136 did: repoIdentifier, 137 collection: BLUESKY_PROFILE_COLLECTION, 138 rkey: "self", 139 }); 140 const avatar = profile?.avatar; 141 const avatarCdnUrl = isBlobWithCdn(avatar) ? avatar.cdnUrl : undefined; 142 const avatarCid = avatarCdnUrl ? undefined : getAvatarCid(profile); 143 144 const Comp: React.ComponentType<BlueskyPostRendererInjectedProps> = useMemo( 145 () => renderer ?? ((props) => <BlueskyPostRenderer {...props} />), 146 [renderer] 147 ); 148 149 const displayHandle = 150 handle ?? (handleOrDid.startsWith("did:") ? undefined : handleOrDid); 151 const authorHandle = 152 displayHandle ?? formatDidForLabel(resolvedDid ?? handleOrDid); 153 const atUri = resolvedDid 154 ? `at://${resolvedDid}/${BLUESKY_POST_COLLECTION}/${rkey}` 155 : undefined; 156 157 const Wrapped = useMemo(() => { 158 const WrappedComponent: React.FC<{ 159 record: FeedPostRecord; 160 loading: boolean; 161 error?: Error; 162 }> = (props) => { 163 const { url: avatarUrlFromBlob } = useBlob(repoIdentifier, avatarCid); 164 const avatarUrl = avatarCdnUrl || avatarUrlFromBlob; 165 return ( 166 <Comp 167 {...props} 168 authorHandle={authorHandle} 169 authorDid={repoIdentifier} 170 avatarUrl={avatarUrl} 171 iconPlacement={iconPlacement} 172 showIcon={showIcon} 173 atUri={atUri} 174 /> 175 ); 176 }; 177 WrappedComponent.displayName = "BlueskyPostWrappedRenderer"; 178 return WrappedComponent; 179 }, [ 180 Comp, 181 repoIdentifier, 182 avatarCid, 183 avatarCdnUrl, 184 authorHandle, 185 iconPlacement, 186 showIcon, 187 atUri, 188 ]); 189 190 if (!displayHandle && resolvingIdentity) { 191 return <div style={{ padding: 8 }}>Resolving handle</div>; 192 } 193 if (!displayHandle && resolutionError) { 194 return ( 195 <div style={{ padding: 8, color: "crimson" }}> 196 Could not resolve handle. 197 </div> 198 ); 199 } 200 201 202 if (record !== undefined) { 203 return ( 204 <AtProtoRecord<FeedPostRecord> 205 record={record} 206 renderer={Wrapped} 207 fallback={fallback} 208 loadingIndicator={loadingIndicator} 209 /> 210 ); 211 } 212 213 return ( 214 <AtProtoRecord<FeedPostRecord> 215 did={repoIdentifier} 216 collection={BLUESKY_POST_COLLECTION} 217 rkey={rkey} 218 renderer={Wrapped} 219 fallback={fallback} 220 loadingIndicator={loadingIndicator} 221 /> 222 ); 223}); 224 225export default BlueskyPost;