From 9bb5bf070783765424dd25d18084241720a4777f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Jan 2026 21:56:11 +0000 Subject: [PATCH] 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 --- src/components/nostr/RichText.tsx | 47 +++++++++++++++++++---- src/components/nostr/RichText/Gallery.tsx | 21 +++++++++- src/components/nostr/RichText/Link.tsx | 21 +++++++++- src/lib/imeta.ts | 11 ++++++ 4 files changed, 91 insertions(+), 9 deletions(-) diff --git a/src/components/nostr/RichText.tsx b/src/components/nostr/RichText.tsx index d5487a3..a6b4cac 100644 --- a/src/components/nostr/RichText.tsx +++ b/src/components/nostr/RichText.tsx @@ -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 | null>(null); + +export function useMediaRenderer() { + return useContext(MediaRendererContext); +} + +// Context for passing the source event (for imeta lookup) +const EventContext = createContext(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; 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 ( -
- {children} - {renderedContent} -
+ + +
+ {children} + {renderedContent} +
+
+
); diff --git a/src/components/nostr/RichText/Gallery.tsx b/src/components/nostr/RichText/Gallery.tsx index 659d470..d4441f0 100644 --- a/src/components/nostr/RichText/Gallery.tsx +++ b/src/components/nostr/RichText/Gallery.tsx @@ -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 ; + } return ; } return ; } if (isVideoURL(url)) { if (shouldShowMedia && options.showVideos) { + if (CustomMediaRenderer) { + return ; + } return ; } return ; } if (isAudioURL(url)) { if (shouldShowMedia && options.showAudio) { + if (CustomMediaRenderer) { + return ; + } return ( [{type}]; @@ -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 ; + } return ( ; + } return ( ; + } return ( <> 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 */