refactor: add global loadMedia setting with CompactMediaRenderer

- Add `loadMedia` setting to Appearance section (enabled by default)
- Create CompactMediaRenderer in src/components/nostr/ (renamed from ChatMediaRenderer)
- Link.tsx and Gallery.tsx now check the setting and use CompactMediaRenderer when disabled
- Tooltip format improved: "Field <value>" with field name and value side by side
  - Shows: Hash, Size, Dimensions, Type, Duration, Alt
- Remove renderMedia prop from ChatViewer (now automatic based on setting)
- Delete old ChatMediaRenderer.tsx

This makes media rendering a site-wide setting rather than chat-specific.

https://claude.ai/code/session_01AeeN5d5EcVLGjZbGueZxaD
This commit is contained in:
Claude
2026-01-30 13:50:35 +00:00
parent b8b71f8b02
commit ec7ae6933c
7 changed files with 94 additions and 67 deletions

View File

@@ -40,7 +40,6 @@ import {
} from "@/lib/chat/group-system-messages";
import { UserName } from "./nostr/UserName";
import { RichText } from "./nostr/RichText";
import { ChatMediaRenderer } from "./chat/ChatMediaRenderer";
import Timestamp from "./Timestamp";
import { ReplyPreview } from "./chat/ReplyPreview";
import { MembersDropdown } from "./chat/MembersDropdown";
@@ -459,11 +458,7 @@ const MessageItem = memo(function MessageItem({
</div>
<div className="break-words overflow-hidden">
{message.event ? (
<RichText
className="text-sm leading-tight"
event={message.event}
renderMedia={ChatMediaRenderer}
>
<RichText className="text-sm leading-tight" event={message.event}>
{message.replyTo && (
<ReplyPreview
replyTo={message.replyTo}

View File

@@ -87,6 +87,27 @@ export function SettingsViewer() {
}
/>
</div>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<label
htmlFor="load-media"
className="text-base font-medium cursor-pointer"
>
Load media
</label>
<p className="text-xs text-muted-foreground">
Render links to media as inline images, videos, and audio
</p>
</div>
<Switch
id="load-media"
checked={settings?.appearance?.loadMedia ?? true}
onCheckedChange={(checked: boolean) =>
updateSetting("appearance", "loadMedia", checked)
}
/>
</div>
</div>
</TabsContent>

View File

@@ -1,5 +1,5 @@
/**
* Chat-specific media renderer
* Compact media renderer for RichText
*
* Shows compact inline file info with expandable media:
* [icon] truncated-hash [blossom]
@@ -111,7 +111,7 @@ function MediaIcon({ type }: { type: "image" | "video" | "audio" }) {
}
}
export function ChatMediaRenderer({ url, type, imeta }: MediaRendererProps) {
export function CompactMediaRenderer({ url, type, imeta }: MediaRendererProps) {
const { addWindow } = useGrimoire();
const [expanded, setExpanded] = useState(false);
@@ -160,19 +160,43 @@ export function ChatMediaRenderer({ url, type, imeta }: MediaRendererProps) {
}
// Build tooltip content from imeta if available
// Format: "Field <value>" with field name and value side by side
const tooltipContent = imeta ? (
<div className="space-y-0.5 text-xs">
{imeta.alt && <div className="font-medium">{imeta.alt}</div>}
{imeta.m && <div className="text-muted-foreground">{imeta.m}</div>}
{imeta.dim && <div className="text-muted-foreground">{imeta.dim}</div>}
<div className="space-y-1 text-xs">
{imeta.x && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0">Hash</span>
<span className="font-mono truncate max-w-48">{imeta.x}</span>
</div>
)}
{imeta.size && (
<div className="text-muted-foreground">
{formatFileSize(imeta.size)}
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0">Size</span>
<span>{formatFileSize(imeta.size)}</span>
</div>
)}
{imeta.dim && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0">Dimensions</span>
<span>{imeta.dim}</span>
</div>
)}
{imeta.m && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0">Type</span>
<span>{imeta.m}</span>
</div>
)}
{imeta.duration && (
<div className="text-muted-foreground">
{formatDuration(imeta.duration)}
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0">Duration</span>
<span>{formatDuration(imeta.duration)}</span>
</div>
)}
{imeta.alt && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0">Alt</span>
<span className="truncate max-w-48">{imeta.alt}</span>
</div>
)}
</div>

View File

@@ -26,14 +26,6 @@ export interface MediaRendererProps {
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);
@@ -113,8 +105,6 @@ 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;
}
@@ -143,12 +133,8 @@ export function RichText({
depth = 1,
options = {},
parserOptions = {},
renderMedia,
children,
}: RichTextProps) {
// Get parent media renderer to inherit if not explicitly overridden
const parentMediaRenderer = useMediaRenderer();
// Merge provided options with defaults
const mergedOptions: Required<RichTextOptions> = {
...defaultOptions,
@@ -194,19 +180,15 @@ export function RichText({
return (
<DepthContext.Provider value={depth}>
<OptionsContext.Provider value={mergedOptions}>
<MediaRendererContext.Provider
value={renderMedia !== undefined ? renderMedia : parentMediaRenderer}
>
<EventContext.Provider value={event ?? null}>
<div
dir="auto"
className={cn("leading-relaxed break-words", className)}
>
{children}
{renderedContent}
</div>
</EventContext.Provider>
</MediaRendererContext.Provider>
<EventContext.Provider value={event ?? null}>
<div
dir="auto"
className={cn("leading-relaxed break-words", className)}
>
{children}
{renderedContent}
</div>
</EventContext.Provider>
</OptionsContext.Provider>
</DepthContext.Provider>
);

View File

@@ -6,12 +6,10 @@ import {
} from "applesauce-core/helpers/url";
import { MediaDialog } from "../MediaDialog";
import { MediaEmbed } from "../MediaEmbed";
import {
useRichTextOptions,
useMediaRenderer,
useRichTextEvent,
} from "../RichText";
import { CompactMediaRenderer } from "../CompactMediaRenderer";
import { useRichTextOptions, useRichTextEvent } from "../RichText";
import { findImetaForUrl } from "@/lib/imeta";
import { useSettings } from "@/hooks/useSettings";
function MediaPlaceholder({
type,
@@ -29,11 +27,14 @@ interface GalleryNodeProps {
export function Gallery({ node }: GalleryNodeProps) {
const options = useRichTextOptions();
const CustomMediaRenderer = useMediaRenderer();
const event = useRichTextEvent();
const { settings } = useSettings();
const [dialogOpen, setDialogOpen] = useState(false);
const [initialIndex, setInitialIndex] = useState(0);
// Check global loadMedia setting
const loadMedia = settings?.appearance?.loadMedia ?? true;
const links = node.links || [];
const handleAudioClick = (index: number) => {
@@ -50,8 +51,8 @@ export function Gallery({ node }: GalleryNodeProps) {
if (isImageURL(url)) {
if (shouldShowMedia && options.showImages) {
if (CustomMediaRenderer) {
return <CustomMediaRenderer url={url} type="image" imeta={imeta} />;
if (!loadMedia) {
return <CompactMediaRenderer url={url} type="image" imeta={imeta} />;
}
return <MediaEmbed url={url} type="image" preset="grid" enableZoom />;
}
@@ -59,8 +60,8 @@ export function Gallery({ node }: GalleryNodeProps) {
}
if (isVideoURL(url)) {
if (shouldShowMedia && options.showVideos) {
if (CustomMediaRenderer) {
return <CustomMediaRenderer url={url} type="video" imeta={imeta} />;
if (!loadMedia) {
return <CompactMediaRenderer url={url} type="video" imeta={imeta} />;
}
return <MediaEmbed url={url} type="video" preset="grid" />;
}
@@ -68,8 +69,8 @@ export function Gallery({ node }: GalleryNodeProps) {
}
if (isAudioURL(url)) {
if (shouldShowMedia && options.showAudio) {
if (CustomMediaRenderer) {
return <CustomMediaRenderer url={url} type="audio" imeta={imeta} />;
if (!loadMedia) {
return <CompactMediaRenderer url={url} type="audio" imeta={imeta} />;
}
return (
<MediaEmbed

View File

@@ -7,12 +7,10 @@ import {
import { MediaDialog } from "../MediaDialog";
import { MediaEmbed } from "../MediaEmbed";
import { PlainLink } from "../LinkPreview";
import {
useRichTextOptions,
useMediaRenderer,
useRichTextEvent,
} from "../RichText";
import { CompactMediaRenderer } from "../CompactMediaRenderer";
import { useRichTextOptions, useRichTextEvent } from "../RichText";
import { findImetaForUrl } from "@/lib/imeta";
import { useSettings } from "@/hooks/useSettings";
function MediaPlaceholder({ type }: { type: "image" | "video" | "audio" }) {
return <span className="text-muted-foreground">[{type}]</span>;
@@ -26,11 +24,14 @@ interface LinkNodeProps {
export function Link({ node }: LinkNodeProps) {
const options = useRichTextOptions();
const CustomMediaRenderer = useMediaRenderer();
const event = useRichTextEvent();
const { settings } = useSettings();
const [dialogOpen, setDialogOpen] = useState(false);
const { href } = node;
// Check global loadMedia setting
const loadMedia = settings?.appearance?.loadMedia ?? true;
// Look up imeta for this URL if event is available
const imeta = event ? findImetaForUrl(event, href) : undefined;
@@ -44,8 +45,8 @@ 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} />;
if (!loadMedia) {
return <CompactMediaRenderer url={href} type="image" imeta={imeta} />;
}
return (
<MediaEmbed
@@ -62,8 +63,8 @@ export function Link({ node }: LinkNodeProps) {
if (isVideoURL(href)) {
if (shouldShowMedia && options.showVideos) {
if (CustomMediaRenderer) {
return <CustomMediaRenderer url={href} type="video" imeta={imeta} />;
if (!loadMedia) {
return <CompactMediaRenderer url={href} type="video" imeta={imeta} />;
}
return (
<MediaEmbed
@@ -79,8 +80,8 @@ export function Link({ node }: LinkNodeProps) {
if (isAudioURL(href)) {
if (shouldShowMedia && options.showAudio) {
if (CustomMediaRenderer) {
return <CustomMediaRenderer url={href} type="audio" imeta={imeta} />;
if (!loadMedia) {
return <CompactMediaRenderer url={href} type="audio" imeta={imeta} />;
}
return (
<>

View File

@@ -22,6 +22,8 @@ export interface PostSettings {
export interface AppearanceSettings {
/** Show client tags in event UI */
showClientTags: boolean;
/** Load media inline (images, videos, audio) - when false, show compact links */
loadMedia: boolean;
}
/**
@@ -43,6 +45,7 @@ const DEFAULT_POST_SETTINGS: PostSettings = {
const DEFAULT_APPEARANCE_SETTINGS: AppearanceSettings = {
showClientTags: true,
loadMedia: true,
};
export const DEFAULT_SETTINGS: AppSettings = {