mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-11 07:56:50 +02:00
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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user