import React from "react"; import type { FeedPostRecord } from "../types/bluesky"; import { useColorScheme, type ColorSchemePreference, } from "../hooks/useColorScheme"; 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 type { BlobWithCdn } from "../hooks/useBlueskyAppview"; export interface BlueskyPostRendererProps { record: FeedPostRecord; loading: boolean; error?: Error; // Optionally pass in actor display info if pre-fetched authorHandle?: string; authorDisplayName?: string; avatarUrl?: string; colorScheme?: ColorSchemePreference; authorDid?: string; embed?: React.ReactNode; iconPlacement?: "cardBottomRight" | "timestamp" | "linkInline"; showIcon?: boolean; atUri?: string; } export const BlueskyPostRenderer: React.FC = ({ record, loading, error, authorDisplayName, authorHandle, avatarUrl, colorScheme = "system", authorDid, embed, iconPlacement = "timestamp", showIcon = true, atUri, }) => { const scheme = useColorScheme(colorScheme); 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 palette = scheme === "dark" ? themeStyles.dark : themeStyles.light; 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, scheme); 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, ...palette.card, ...(iconPlacement === "cardBottomRight" && showIcon ? { paddingBottom: cardPadding + 16 } : {}), }; return (
{avatarUrl ? ( avatar ) : (
)}
{primaryName} {authorDisplayName && authorHandle && ( @{authorHandle} )}
{iconPlacement === "timestamp" && showIcon && (
{makeIcon()}
)}
{replyHref && replyLabel && (
Replying to{" "} {replyLabel}
)}

{text}

{record.facets && record.facets.length > 0 && (
{record.facets.map((_, idx) => ( facet ))}
)}
{postUrl && ( View on Bluesky {iconPlacement === "linkInline" && showIcon && ( {makeIcon()} )} )}
{resolvedEmbed && (
{resolvedEmbed}
)}
{iconPlacement === "cardBottomRight" && showIcon && (
{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, }, timestampIcon: { display: "flex", alignItems: "center", justifyContent: "center", }, body: { fontSize: 14, lineHeight: 1.4, }, text: { margin: 0, whiteSpace: "pre-wrap", overflowWrap: "anywhere", }, facets: { marginTop: 8, display: "flex", gap: 4, }, embedContainer: { marginTop: 12, padding: 8, borderRadius: 12, 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", }, facetTag: { padding: "2px 6px", borderRadius: 4, fontSize: 11, }, replyLine: { fontSize: 12, }, replyLink: { textDecoration: "none", fontWeight: 500, }, iconCorner: { position: "absolute", right: 12, bottom: 12, display: "flex", alignItems: "center", justifyContent: "flex-end", }, }; const themeStyles = { light: { card: { border: "1px solid #e2e8f0", background: "#ffffff", color: "#0f172a", }, avatarPlaceholder: { background: "#cbd5e1", }, handle: { color: "#64748b", }, time: { color: "#94a3b8", }, text: { color: "#0f172a", }, facetTag: { background: "#f1f5f9", color: "#475569", }, replyLine: { color: "#475569", }, replyLink: { color: "#2563eb", }, embedContainer: { border: "1px solid #e2e8f0", borderRadius: 12, background: "#f8fafc", }, postLink: { color: "#2563eb", }, }, dark: { card: { border: "1px solid #1e293b", background: "#0f172a", color: "#e2e8f0", }, avatarPlaceholder: { background: "#1e293b", }, handle: { color: "#cbd5f5", }, time: { color: "#94a3ff", }, text: { color: "#e2e8f0", }, facetTag: { background: "#1e293b", color: "#e0f2fe", }, replyLine: { color: "#cbd5f5", }, replyLink: { color: "#38bdf8", }, embedContainer: { border: "1px solid #1e293b", borderRadius: 12, background: "#0b1120", }, postLink: { color: "#38bdf8", }, }, } satisfies Record<"light" | "dark", Record>; 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, scheme: "light" | "dark", ): 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; scheme: "light" | "dark"; } const ImagesEmbed: React.FC = ({ embed, did, scheme }) => { if (!embed.images || embed.images.length === 0) return null; const palette = scheme === "dark" ? imagesPalette.dark : imagesPalette.light; const columns = embed.images.length > 1 ? "repeat(auto-fit, minmax(160px, 1fr))" : "1fr"; return (
{embed.images.map((image, idx) => ( ))}
); }; interface PostImageProps { image: ImagesEmbedType["images"][number]; did?: string; scheme: "light" | "dark"; } const PostImage: React.FC = ({ image, did, scheme }) => { // Check if the image has a CDN URL from the appview (preferred) const imageBlob = image.image; const cdnUrl = isBlobWithCdn(imageBlob) ? imageBlob.cdnUrl : undefined; const cid = !cdnUrl ? extractCidFromImageBlob(imageBlob) : undefined; const { url: urlFromBlob, loading, error } = useBlob(did, cid); // Use CDN URL from appview if available, otherwise use blob URL const url = cdnUrl || urlFromBlob; const alt = image.alt?.trim() || "Bluesky attachment"; const palette = scheme === "dark" ? imagesPalette.dark : imagesPalette.light; 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"}
)}
{image.alt && image.alt.trim().length > 0 && (
{image.alt}
)}
); }; /** * Type guard to check if a blob has a CDN URL from appview. */ function isBlobWithCdn(value: unknown): value is BlobWithCdn { if (typeof value !== "object" || value === null) return false; const obj = value as Record; return ( obj.$type === "blob" && typeof obj.cdnUrl === "string" && typeof obj.ref === "object" && obj.ref !== null && typeof (obj.ref as { $link?: unknown }).$link === "string" ); } /** * Helper to extract CID from image blob. */ function extractCidFromImageBlob(blob: unknown): string | undefined { if (typeof blob !== "object" || blob === null) return undefined; const blobObj = blob as { ref?: { $link?: string }; cid?: string; }; if (typeof blobObj.cid === "string") return blobObj.cid; if (typeof blobObj.ref === "object" && blobObj.ref !== null) { const link = blobObj.ref.$link; if (typeof link === "string") return link; } return undefined; } 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, }; const imagesPalette = { light: { container: { padding: 0, } satisfies React.CSSProperties, item: {}, media: { background: "#e2e8f0", } satisfies React.CSSProperties, placeholder: { color: "#475569", } satisfies React.CSSProperties, caption: { color: "#475569", } satisfies React.CSSProperties, }, dark: { container: { padding: 0, } satisfies React.CSSProperties, item: {}, media: { background: "#1e293b", } satisfies React.CSSProperties, placeholder: { color: "#cbd5f5", } satisfies React.CSSProperties, caption: { color: "#94a3b8", } satisfies React.CSSProperties, }, } as const; export default BlueskyPostRenderer;