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