feat: add custom media renderer support to RichText

Add renderMedia prop to RichText component that allows overriding
how images, videos, and audio are rendered across the entire
RichText subtree (including Link and Gallery components).

The custom renderer receives:
- url: the media URL
- type: "image" | "video" | "audio"
- imeta: optional NIP-92 metadata (dimensions, blurhash, alt, etc.)

This enables chat-specific media rendering with compact thumbnails,
lightboxes, or any custom presentation.

https://claude.ai/code/session_01AeeN5d5EcVLGjZbGueZxaD
This commit is contained in:
Claude
2026-01-29 21:56:11 +00:00
parent a75dd2b3fb
commit 9bb5bf0707
4 changed files with 91 additions and 9 deletions

View File

@@ -14,6 +14,32 @@ import { nipReferences } from "@/lib/nip-transformer";
import { relayReferences } from "@/lib/relay-transformer";
import type { NostrEvent } from "@/types/nostr";
import type { Root } from "applesauce-content/nast";
import type { ImetaEntry } from "@/lib/imeta";
/**
* Props for custom media renderers
*/
export interface MediaRendererProps {
url: string;
type: "image" | "video" | "audio";
/** Image/video metadata from imeta tags (NIP-92) if available */
imeta?: ImetaEntry;
}
// Context for custom media rendering across RichText subtree
const MediaRendererContext =
createContext<React.ComponentType<MediaRendererProps> | null>(null);
export function useMediaRenderer() {
return useContext(MediaRendererContext);
}
// Context for passing the source event (for imeta lookup)
const EventContext = createContext<NostrEvent | null>(null);
export function useRichTextEvent() {
return useContext(EventContext);
}
/** Transformer function type compatible with applesauce-content */
export type ContentTransformer = () => (tree: Root) => void;
@@ -87,6 +113,8 @@ interface RichTextProps {
options?: RichTextOptions;
/** Parser options for customizing content parsing */
parserOptions?: ParserOptions;
/** Custom media renderer for images, videos, and audio */
renderMedia?: React.ComponentType<MediaRendererProps>;
children?: React.ReactNode;
}
@@ -115,6 +143,7 @@ export function RichText({
depth = 1,
options = {},
parserOptions = {},
renderMedia,
children,
}: RichTextProps) {
// Merge provided options with defaults
@@ -162,13 +191,17 @@ export function RichText({
return (
<DepthContext.Provider value={depth}>
<OptionsContext.Provider value={mergedOptions}>
<div
dir="auto"
className={cn("leading-relaxed break-words", className)}
>
{children}
{renderedContent}
</div>
<MediaRendererContext.Provider value={renderMedia ?? null}>
<EventContext.Provider value={event ?? null}>
<div
dir="auto"
className={cn("leading-relaxed break-words", className)}
>
{children}
{renderedContent}
</div>
</EventContext.Provider>
</MediaRendererContext.Provider>
</OptionsContext.Provider>
</DepthContext.Provider>
);

View File

@@ -6,7 +6,12 @@ import {
} from "applesauce-core/helpers/url";
import { MediaDialog } from "../MediaDialog";
import { MediaEmbed } from "../MediaEmbed";
import { useRichTextOptions } from "../RichText";
import {
useRichTextOptions,
useMediaRenderer,
useRichTextEvent,
} from "../RichText";
import { findImetaForUrl } from "@/lib/imeta";
function MediaPlaceholder({
type,
@@ -24,6 +29,8 @@ interface GalleryNodeProps {
export function Gallery({ node }: GalleryNodeProps) {
const options = useRichTextOptions();
const CustomMediaRenderer = useMediaRenderer();
const event = useRichTextEvent();
const [dialogOpen, setDialogOpen] = useState(false);
const [initialIndex, setInitialIndex] = useState(0);
@@ -38,20 +45,32 @@ export function Gallery({ node }: GalleryNodeProps) {
// Check if media should be shown
const shouldShowMedia = options.showMedia;
// Look up imeta for this URL if event is available
const imeta = event ? findImetaForUrl(event, url) : undefined;
if (isImageURL(url)) {
if (shouldShowMedia && options.showImages) {
if (CustomMediaRenderer) {
return <CustomMediaRenderer url={url} type="image" imeta={imeta} />;
}
return <MediaEmbed url={url} type="image" preset="grid" enableZoom />;
}
return <MediaPlaceholder type="image" />;
}
if (isVideoURL(url)) {
if (shouldShowMedia && options.showVideos) {
if (CustomMediaRenderer) {
return <CustomMediaRenderer url={url} type="video" imeta={imeta} />;
}
return <MediaEmbed url={url} type="video" preset="grid" />;
}
return <MediaPlaceholder type="video" />;
}
if (isAudioURL(url)) {
if (shouldShowMedia && options.showAudio) {
if (CustomMediaRenderer) {
return <CustomMediaRenderer url={url} type="audio" imeta={imeta} />;
}
return (
<MediaEmbed
url={url}

View File

@@ -7,7 +7,12 @@ import {
import { MediaDialog } from "../MediaDialog";
import { MediaEmbed } from "../MediaEmbed";
import { PlainLink } from "../LinkPreview";
import { useRichTextOptions } from "../RichText";
import {
useRichTextOptions,
useMediaRenderer,
useRichTextEvent,
} from "../RichText";
import { findImetaForUrl } from "@/lib/imeta";
function MediaPlaceholder({ type }: { type: "image" | "video" | "audio" }) {
return <span className="text-muted-foreground">[{type}]</span>;
@@ -21,9 +26,14 @@ interface LinkNodeProps {
export function Link({ node }: LinkNodeProps) {
const options = useRichTextOptions();
const CustomMediaRenderer = useMediaRenderer();
const event = useRichTextEvent();
const [dialogOpen, setDialogOpen] = useState(false);
const { href } = node;
// Look up imeta for this URL if event is available
const imeta = event ? findImetaForUrl(event, href) : undefined;
const handleAudioClick = () => {
setDialogOpen(true);
};
@@ -34,6 +44,9 @@ export function Link({ node }: LinkNodeProps) {
// Render appropriate link type
if (isImageURL(href)) {
if (shouldShowMedia && options.showImages) {
if (CustomMediaRenderer) {
return <CustomMediaRenderer url={href} type="image" imeta={imeta} />;
}
return (
<MediaEmbed
url={href}
@@ -49,6 +62,9 @@ export function Link({ node }: LinkNodeProps) {
if (isVideoURL(href)) {
if (shouldShowMedia && options.showVideos) {
if (CustomMediaRenderer) {
return <CustomMediaRenderer url={href} type="video" imeta={imeta} />;
}
return (
<MediaEmbed
url={href}
@@ -63,6 +79,9 @@ export function Link({ node }: LinkNodeProps) {
if (isAudioURL(href)) {
if (shouldShowMedia && options.showAudio) {
if (CustomMediaRenderer) {
return <CustomMediaRenderer url={href} type="audio" imeta={imeta} />;
}
return (
<>
<MediaEmbed

View File

@@ -64,6 +64,17 @@ export function parseImetaTags(event: NostrEvent): ImetaEntry[] {
.filter((entry): entry is ImetaEntry => entry !== null);
}
/**
* Find imeta entry for a specific URL
*/
export function findImetaForUrl(
event: NostrEvent,
url: string,
): ImetaEntry | undefined {
const entries = parseImetaTags(event);
return entries.find((entry) => entry.url === url);
}
/**
* Parse file metadata from NIP-94 kind 1063 event tags
*/