import React from "react"; import type { FeedPostRecord } from "../types/bluesky"; import { parseAtUri, toBlueskyPostUrl, formatDidForLabel, type ParsedAtUri, } from "../utils/at-uri"; import { useDidResolution } from "../hooks/useDidResolution"; import { useBlob } from "../hooks/useBlob"; import { BlueskyIcon } from "../components/BlueskyIcon"; import { isBlobWithCdn, extractCidFromBlob } from "../utils/blob"; import { RichText } from "../components/RichText"; export interface BlueskyPostRendererProps { record: FeedPostRecord; loading: boolean; error?: Error; authorHandle?: string; authorDisplayName?: string; avatarUrl?: string; authorDid?: string; embed?: React.ReactNode; iconPlacement?: "cardBottomRight" | "timestamp" | "linkInline"; showIcon?: boolean; atUri?: string; isInThread?: boolean; threadDepth?: number; isQuotePost?: boolean; showThreadBorder?: boolean; } export const BlueskyPostRenderer: React.FC = ({ record, loading, error, authorDisplayName, authorHandle, avatarUrl, authorDid, embed, iconPlacement = "timestamp", showIcon = true, atUri, isInThread = false, threadDepth = 0, isQuotePost = false, showThreadBorder = false }) => { void threadDepth; const replyParentUri = record.reply?.parent?.uri; const replyTarget = replyParentUri ? parseAtUri(replyParentUri) : undefined; const { handle: parentHandle, loading: parentHandleLoading } = useDidResolution(replyTarget?.did); if (error) { return (
Failed to load post.
); } if (loading && !record) return
Loading…
; const text = record.text; const createdDate = new Date(record.createdAt); const created = createdDate.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short", }); const primaryName = authorDisplayName || authorHandle || "…"; const replyHref = replyTarget ? toBlueskyPostUrl(replyTarget) : undefined; const replyLabel = replyTarget ? formatReplyLabel(replyTarget, parentHandle, parentHandleLoading) : undefined; const makeIcon = () => (showIcon ? : null); const resolvedEmbed = embed ?? createAutoEmbed(record, authorDid); const parsedSelf = atUri ? parseAtUri(atUri) : undefined; const postUrl = parsedSelf ? toBlueskyPostUrl(parsedSelf) : undefined; const cardPadding = typeof baseStyles.card.padding === "number" ? baseStyles.card.padding : 12; const cardStyle: React.CSSProperties = { ...baseStyles.card, border: (isInThread && !isQuotePost && !showThreadBorder) ? "none" : `1px solid var(--atproto-color-border)`, background: `var(--atproto-color-bg)`, color: `var(--atproto-color-text)`, borderRadius: (isInThread && !isQuotePost && !showThreadBorder) ? "0" : "12px", ...(iconPlacement === "cardBottomRight" && showIcon && !isInThread ? { paddingBottom: cardPadding + 16 } : {}), }; return (
{isInThread ? ( ) : ( )}
); }; interface LayoutProps { avatarUrl?: string; primaryName: string; authorDisplayName?: string; authorHandle?: string; iconPlacement: "cardBottomRight" | "timestamp" | "linkInline"; showIcon: boolean; makeIcon: () => React.ReactNode; replyHref?: string; replyLabel?: string; text: string; record: FeedPostRecord; created: string; postUrl?: string; resolvedEmbed: React.ReactNode; } const AuthorInfo: React.FC<{ primaryName: string; authorDisplayName?: string; authorHandle?: string; inline?: boolean; }> = ({ primaryName, authorDisplayName, authorHandle, inline = false }) => (
{authorDisplayName || primaryName} {authorHandle && ( @{authorHandle} )}
); const Avatar: React.FC<{ avatarUrl?: string }> = ({ avatarUrl }) => avatarUrl ? ( avatar ) : (
); const ReplyInfo: React.FC<{ replyHref?: string; replyLabel?: string; marginBottom?: number; }> = ({ replyHref, replyLabel, marginBottom = 0 }) => replyHref && replyLabel ? (
Replying to{" "} {replyLabel}
) : null; const PostContent: React.FC<{ text: string; record: FeedPostRecord; created: string; postUrl?: string; iconPlacement: "cardBottomRight" | "timestamp" | "linkInline"; showIcon: boolean; makeIcon: () => React.ReactNode; resolvedEmbed: React.ReactNode; }> = ({ text, record, created, postUrl, iconPlacement, showIcon, makeIcon, resolvedEmbed, }) => (

{resolvedEmbed && (
{resolvedEmbed}
)}
{postUrl && ( View on Bluesky {iconPlacement === "linkInline" && showIcon && ( {makeIcon()} )} )}
); const ThreadLayout: React.FC = (props) => (
{props.iconPlacement === "timestamp" && props.showIcon && (
{props.makeIcon()}
)}
{props.iconPlacement === "cardBottomRight" && props.showIcon && (
{props.makeIcon()}
)}
); const DefaultLayout: React.FC = (props) => ( <>
{props.iconPlacement === "timestamp" && props.showIcon && (
{props.makeIcon()}
)}
{props.iconPlacement === "cardBottomRight" && props.showIcon && (
{props.makeIcon()}
)} ); const baseStyles: Record = { card: { borderRadius: 12, padding: 12, fontFamily: "system-ui, sans-serif", display: "flex", flexDirection: "column", gap: 8, maxWidth: 600, transition: "background-color 180ms ease, border-color 180ms ease, color 180ms ease", position: "relative", }, header: { display: "flex", alignItems: "center", gap: 8, }, headerIcon: { marginLeft: "auto", display: "flex", alignItems: "center", }, avatarPlaceholder: { width: 40, height: 40, borderRadius: "50%", }, avatarImg: { width: 40, height: 40, borderRadius: "50%", objectFit: "cover", }, handle: { fontSize: 12, }, time: { fontSize: 11, }, body: { fontSize: 14, lineHeight: 1.4, }, text: { margin: 0, whiteSpace: "pre-wrap", overflowWrap: "anywhere", }, embedContainer: { marginTop: 12, padding: 8, borderRadius: 12, border: `1px solid var(--atproto-color-border)`, background: `var(--atproto-color-bg-elevated)`, display: "flex", flexDirection: "column", gap: 8, }, timestampRow: { display: "flex", justifyContent: "flex-end", alignItems: "center", gap: 12, marginTop: 12, flexWrap: "wrap", }, linkWithIcon: { display: "inline-flex", alignItems: "center", gap: 6, }, postLink: { fontSize: 11, textDecoration: "none", fontWeight: 600, }, inlineIcon: { display: "inline-flex", alignItems: "center", }, replyLine: { fontSize: 12, }, replyLink: { textDecoration: "none", fontWeight: 500, }, iconCorner: { position: "absolute", right: 12, bottom: 12, display: "flex", alignItems: "center", justifyContent: "flex-end", }, }; function formatReplyLabel( target: ParsedAtUri, resolvedHandle?: string, loading?: boolean, ): string { if (resolvedHandle) return `@${resolvedHandle}`; if (loading) return "…"; return `@${formatDidForLabel(target.did)}`; } function createAutoEmbed( record: FeedPostRecord, authorDid: string | undefined, ): React.ReactNode { const embed = record.embed as { $type?: string } | undefined; if (!embed) return null; if (embed.$type === "app.bsky.embed.images") { return ; } if (embed.$type === "app.bsky.embed.recordWithMedia") { const media = (embed as RecordWithMediaEmbed).media; if (media?.$type === "app.bsky.embed.images") { return ( ); } } return null; } type ImagesEmbedType = { $type: "app.bsky.embed.images"; images: Array<{ alt?: string; mime?: string; size?: number; image?: { $type?: string; ref?: { $link?: string }; cid?: string; }; aspectRatio?: { width: number; height: number; }; }>; }; type RecordWithMediaEmbed = { $type: "app.bsky.embed.recordWithMedia"; record?: unknown; media?: { $type?: string }; }; interface ImagesEmbedProps { embed: ImagesEmbedType; did?: string; } const ImagesEmbed: React.FC = ({ embed, did }) => { if (!embed.images || embed.images.length === 0) return null; const columns = embed.images.length > 1 ? "repeat(auto-fit, minmax(160px, 1fr))" : "1fr"; return (
{embed.images.map((img, idx) => ( ))}
); }; interface PostImageProps { image: ImagesEmbedType["images"][number]; did?: string; } const PostImage: React.FC = ({ image, did }) => { const [showAltText, setShowAltText] = React.useState(false); const imageBlob = image.image; const cdnUrl = isBlobWithCdn(imageBlob) ? imageBlob.cdnUrl : undefined; const cid = cdnUrl ? undefined : extractCidFromBlob(imageBlob); const { url: urlFromBlob, loading, error } = useBlob(did, cid); const url = cdnUrl || urlFromBlob; const alt = image.alt?.trim() || "Bluesky attachment"; const hasAlt = image.alt && image.alt.trim().length > 0; const aspect = image.aspectRatio && image.aspectRatio.height > 0 ? `${image.aspectRatio.width} / ${image.aspectRatio.height}` : undefined; return (
{url ? ( {alt} ) : (
{loading ? "Loading image…" : error ? "Image failed to load" : "Image unavailable"}
)} {hasAlt && ( )}
{hasAlt && showAltText && (
{image.alt}
)}
); }; const imagesBase = { container: { display: "grid", gap: 8, width: "100%", } satisfies React.CSSProperties, item: { margin: 0, display: "flex", flexDirection: "column", gap: 4, } satisfies React.CSSProperties, media: { position: "relative", width: "100%", borderRadius: 12, overflow: "hidden", } satisfies React.CSSProperties, img: { width: "100%", height: "100%", objectFit: "cover", } satisfies React.CSSProperties, placeholder: { display: "flex", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", } satisfies React.CSSProperties, caption: { fontSize: 12, lineHeight: 1.3, } satisfies React.CSSProperties, altBadge: { position: "absolute", bottom: 8, right: 8, padding: "4px 8px", fontSize: 10, fontWeight: 600, letterSpacing: "0.5px", border: "none", borderRadius: 4, cursor: "pointer", transition: "background 150ms ease, color 150ms ease", fontFamily: "system-ui, sans-serif", } satisfies React.CSSProperties, }; export default BlueskyPostRenderer;