mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 00:17:02 +02:00
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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user