feat: Add configurable media rendering options for RichText

Extends RichTextOptions interface with display preferences including:
- Media size presets (compact/normal/large)
- Adaptive grid columns (auto or fixed 1-4)
- Aspect ratio preservation toggle
- Visual polish options (transitions, rounded corners)
- Interaction controls (zoom enable/disable)

Implements adaptive grid layout in Gallery component:
- Auto-adapts columns based on media count (1 col for single, 2 for pair, 3 for multiple)
- Respects fixed column preferences when specified
- Preserves aspect ratios or applies crops based on configuration

Creates media preset configurations for different contexts:
- CHAT_PRESET: Balanced for conversation flow with adaptive columns
- REPLY_PRESET: Minimal for lightweight reply previews
- FEED_PRESET: Efficient for scrolling with compact size
- DETAIL_PRESET: Full quality for focused viewing
- COMPACT_PRESET: Minimal footprint for constrained spaces

Updates ChatViewer to use CHAT_PRESET for optimal chat experience.
This commit is contained in:
Claude
2026-01-14 11:08:06 +00:00
parent 998944fdf7
commit 4a0ec3a741
6 changed files with 255 additions and 10 deletions

View File

@@ -30,6 +30,7 @@ import { parseSlashCommand } from "@/lib/chat/slash-command-parser";
import { UserName } from "./nostr/UserName";
import { RichText } from "./nostr/RichText";
import Timestamp from "./Timestamp";
import { CHAT_PRESET } from "@/lib/media-presets";
import { ReplyPreview } from "./chat/ReplyPreview";
import { MembersDropdown } from "./chat/MembersDropdown";
import { RelaysDropdown } from "./chat/RelaysDropdown";
@@ -174,7 +175,11 @@ const ComposerReplyPreview = memo(function ComposerReplyPreview({
<div className="flex-1 min-w-0 line-clamp-1 overflow-hidden text-muted-foreground">
<RichText
event={replyEvent}
options={{ showMedia: false, showEventEmbeds: false }}
options={{
showMedia: false,
showEventEmbeds: false,
enableTransitions: false,
}}
/>
</div>
<button
@@ -270,7 +275,11 @@ const MessageItem = memo(function MessageItem({
<RichText
event={zapRequest || message.event}
className="text-sm leading-tight break-words"
options={{ showMedia: false, showEventEmbeds: false }}
options={{
showMedia: false,
showEventEmbeds: false,
enableTransitions: false,
}}
/>
)}
</div>
@@ -300,7 +309,11 @@ const MessageItem = memo(function MessageItem({
</div>
<div className="break-words overflow-hidden">
{message.event ? (
<RichText className="text-sm leading-tight" event={message.event}>
<RichText
className="text-sm leading-tight"
event={message.event}
options={CHAT_PRESET}
>
{message.replyTo && (
<ReplyPreview
replyToId={message.replyTo}

View File

@@ -35,6 +35,7 @@ export function useDepth() {
* Configuration options for RichText rendering behavior
*/
export interface RichTextOptions {
// === Visibility Controls ===
/** Show images inline (default: true) */
showImages?: boolean;
/** Show videos inline (default: true) */
@@ -45,6 +46,24 @@ export interface RichTextOptions {
showMedia?: boolean;
/** Show event embeds for note/nevent/naddr mentions (default: true) */
showEventEmbeds?: boolean;
// === Display Preferences ===
/** Media size preset: "compact" | "normal" | "large" (default: "normal") */
mediaSize?: "compact" | "normal" | "large";
/** Gallery columns: "auto" (adaptive) or fixed 1-4 (default: "auto") */
galleryColumns?: "auto" | 1 | 2 | 3 | 4;
// === Visual Polish ===
/** Preserve original aspect ratios instead of forcing crops (default: true) */
preserveAspectRatio?: boolean;
/** Enable smooth fade-in animations (default: true) */
enableTransitions?: boolean;
/** Border radius: "none" | "sm" | "md" | "lg" (default: "md") */
roundedCorners?: "none" | "sm" | "md" | "lg";
// === Interaction ===
/** Enable image zoom on click (default: true) */
enableZoom?: boolean;
}
// Default options
@@ -54,6 +73,12 @@ const defaultOptions: Required<RichTextOptions> = {
showAudio: true,
showMedia: true,
showEventEmbeds: true,
mediaSize: "normal",
galleryColumns: "auto",
preserveAspectRatio: true,
enableTransitions: true,
roundedCorners: "md",
enableZoom: true,
};
// Context for passing options through RichText rendering

View File

@@ -7,6 +7,7 @@ import {
import { MediaDialog } from "../MediaDialog";
import { MediaEmbed } from "../MediaEmbed";
import { useRichTextOptions } from "../RichText";
import { cn } from "@/lib/utils";
function MediaPlaceholder({ type }: { type: "image" | "video" | "audio" }) {
return <span className="text-muted-foreground">[{type}]</span>;
@@ -18,6 +19,50 @@ interface GalleryNodeProps {
};
}
/**
* Determine adaptive column count based on media count
* Logic: 1 image = 1 col, 2 images = 2 cols, 3+ = 3 cols
*/
function getAdaptiveColumns(mediaCount: number): number {
if (mediaCount === 1) return 1;
if (mediaCount === 2) return 2;
return 3;
}
/**
* Get grid column class based on column count
*/
function getGridColumnsClass(columns: number): string {
switch (columns) {
case 1:
return "grid-cols-1";
case 2:
return "grid-cols-2";
case 3:
return "grid-cols-3";
case 4:
return "grid-cols-4";
default:
return "grid-cols-3";
}
}
/**
* Get media preset based on mediaSize option
*/
function getMediaPreset(
mediaSize: "compact" | "normal" | "large",
): "thumbnail" | "grid" | "preview" {
switch (mediaSize) {
case "compact":
return "thumbnail";
case "large":
return "preview";
default:
return "grid";
}
}
export function Gallery({ node }: GalleryNodeProps) {
const options = useRichTextOptions();
const [dialogOpen, setDialogOpen] = useState(false);
@@ -30,19 +75,39 @@ export function Gallery({ node }: GalleryNodeProps) {
setDialogOpen(true);
};
// Determine media preset based on size option
const mediaPreset = getMediaPreset(options.mediaSize);
const renderLink = (url: string, index: number) => {
// Check if media should be shown
const shouldShowMedia = options.showMedia;
if (isImageURL(url)) {
if (shouldShowMedia && options.showImages) {
return <MediaEmbed url={url} type="image" preset="grid" enableZoom />;
return (
<MediaEmbed
url={url}
type="image"
preset={mediaPreset}
enableZoom={options.enableZoom}
fadeIn={options.enableTransitions}
aspectRatio={options.preserveAspectRatio ? "auto" : "1/1"}
/>
);
}
return <MediaPlaceholder type="image" />;
}
if (isVideoURL(url)) {
if (shouldShowMedia && options.showVideos) {
return <MediaEmbed url={url} type="video" preset="grid" />;
return (
<MediaEmbed
url={url}
type="video"
preset={mediaPreset}
fadeIn={options.enableTransitions}
aspectRatio={options.preserveAspectRatio ? "auto" : "16/9"}
/>
);
}
return <MediaPlaceholder type="video" />;
}
@@ -69,11 +134,26 @@ export function Gallery({ node }: GalleryNodeProps) {
const imageLinks = links.filter((url) => isImageURL(url) || isVideoURL(url));
const audioOnlyLinks = links.filter((url) => isAudioURL(url));
// Determine grid columns (adaptive or fixed)
const gridColumns =
options.galleryColumns === "auto"
? getAdaptiveColumns(imageLinks.length)
: options.galleryColumns;
const gridClass = getGridColumnsClass(gridColumns);
// Determine gap size based on media size
const gapClass =
options.mediaSize === "compact"
? "gap-1"
: options.mediaSize === "large"
? "gap-2"
: "gap-1.5";
return (
<>
{/* Grid layout for images/videos */}
{imageLinks.length > 0 && (
<div className="my-2 grid grid-cols-3 gap-1.5">
<div className={cn("my-2 grid", gridClass, gapClass)}>
{imageLinks.map((url: string, i: number) => (
<div key={`${url}-${i}`}>{renderLink(url, links.indexOf(url))}</div>
))}

View File

@@ -19,6 +19,22 @@ interface LinkNodeProps {
};
}
/**
* Get media preset based on mediaSize option for inline links
*/
function getMediaPreset(
mediaSize: "compact" | "normal" | "large",
): "thumbnail" | "inline" | "preview" {
switch (mediaSize) {
case "compact":
return "thumbnail";
case "large":
return "preview";
default:
return "inline";
}
}
export function Link({ node }: LinkNodeProps) {
const options = useRichTextOptions();
const [dialogOpen, setDialogOpen] = useState(false);
@@ -31,6 +47,9 @@ export function Link({ node }: LinkNodeProps) {
// Check if media should be shown
const shouldShowMedia = options.showMedia;
// Determine media preset based on size option
const mediaPreset = getMediaPreset(options.mediaSize);
// Render appropriate link type
if (isImageURL(href)) {
if (shouldShowMedia && options.showImages) {
@@ -38,8 +57,10 @@ export function Link({ node }: LinkNodeProps) {
<MediaEmbed
url={href}
type="image"
preset="inline"
enableZoom
preset={mediaPreset}
enableZoom={options.enableZoom}
fadeIn={options.enableTransitions}
aspectRatio={options.preserveAspectRatio ? "auto" : undefined}
className="my-2 inline-block"
/>
);
@@ -53,7 +74,9 @@ export function Link({ node }: LinkNodeProps) {
<MediaEmbed
url={href}
type="video"
preset="inline"
preset={mediaPreset}
fadeIn={options.enableTransitions}
aspectRatio={options.preserveAspectRatio ? "auto" : undefined}
className="my-2 inline-block"
/>
);

104
src/lib/media-presets.ts Normal file
View File

@@ -0,0 +1,104 @@
import type { RichTextOptions } from "@/components/nostr/RichText";
/**
* Media rendering presets for different contexts
*
* These presets provide optimized configurations for common use cases,
* balancing visual quality, performance, and user experience.
*/
/**
* Chat message preset - Balanced for conversation flow
* - Adaptive grid columns based on media count
* - Normal size with preserved aspect ratios
* - Full interactivity (zoom, transitions)
*/
export const CHAT_PRESET: RichTextOptions = {
showMedia: true,
showImages: true,
showVideos: true,
showAudio: true,
showEventEmbeds: true,
mediaSize: "normal",
galleryColumns: "auto", // Adaptive: 1 col for single, 2 for pair, 3 for multiple
preserveAspectRatio: true,
enableTransitions: true,
roundedCorners: "md",
enableZoom: true,
};
/**
* Reply preview preset - Minimal for context
* - No media shown (or use compact with showMedia: true)
* - Keeps replies lightweight and focused on text
*/
export const REPLY_PRESET: RichTextOptions = {
showMedia: false,
showEventEmbeds: false,
mediaSize: "compact",
galleryColumns: 1,
preserveAspectRatio: true,
enableTransitions: false,
roundedCorners: "sm",
enableZoom: false,
};
/**
* Feed item preset - Efficient for scrolling
* - Compact media size for quick scanning
* - Fixed 2-column grid for consistency
* - Reduced animations for smoother scrolling
*/
export const FEED_PRESET: RichTextOptions = {
showMedia: true,
showImages: true,
showVideos: true,
showAudio: true,
showEventEmbeds: true,
mediaSize: "compact",
galleryColumns: 2,
preserveAspectRatio: true,
enableTransitions: false, // Disable for better scroll performance
roundedCorners: "md",
enableZoom: true,
};
/**
* Detail view preset - Full quality for focused viewing
* - Large media size for detail
* - 2-column grid for elegant presentation
* - All features enabled
*/
export const DETAIL_PRESET: RichTextOptions = {
showMedia: true,
showImages: true,
showVideos: true,
showAudio: true,
showEventEmbeds: true,
mediaSize: "large",
galleryColumns: 2,
preserveAspectRatio: true,
enableTransitions: true,
roundedCorners: "lg",
enableZoom: true,
};
/**
* Compact preset - Minimal footprint
* - Small media with tight spacing
* - Fixed single column
* - Reduced visual effects
*/
export const COMPACT_PRESET: RichTextOptions = {
showMedia: true,
showImages: true,
showVideos: true,
showAudio: true,
showEventEmbeds: false,
mediaSize: "compact",
galleryColumns: 1,
preserveAspectRatio: true,
enableTransitions: false,
roundedCorners: "sm",
enableZoom: false,
};

View File

@@ -1 +1 @@
{"root":["./vite.config.ts"],"version":"5.6.3"}
{"root":["./vite.config.ts"],"errors":true,"version":"5.9.3"}