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