feat: improve ChatMediaRenderer with better UX

- Click to expand shows MediaEmbed (not collapsible once expanded)
- Tooltip only shows when imeta is present, displays: alt, mime type,
  dimensions, size, duration
- More compact representation: [icon] truncated-hash [blossom]
- Pass mediaType to BlossomViewer for correct preview rendering
- BlossomViewer now accepts mediaType hint for preview type detection

https://claude.ai/code/session_01AeeN5d5EcVLGjZbGueZxaD
This commit is contained in:
Claude
2026-01-30 13:32:26 +00:00
parent ff9f3a747c
commit ec422aea61
2 changed files with 96 additions and 85 deletions

View File

@@ -53,6 +53,8 @@ interface BlossomViewerProps {
sha256?: string;
/** Full blob URL with extension (for blob subcommand) */
blobUrl?: string;
/** Media type hint for preview (image/video/audio) */
mediaType?: "image" | "video" | "audio";
}
/**
@@ -66,6 +68,7 @@ export function BlossomViewer({
targetServer,
sha256,
blobUrl,
mediaType,
}: BlossomViewerProps) {
switch (subcommand) {
case "servers":
@@ -82,6 +85,7 @@ export function BlossomViewer({
sha256={sha256!}
serverUrl={serverUrl}
blobUrl={blobUrl}
mediaType={mediaType}
/>
);
case "mirror":
@@ -1007,6 +1011,7 @@ function BlobDetailView({
serverUrl,
blobUrl: providedBlobUrl,
blob: initialBlob,
mediaType: providedMediaType,
onBack,
}: {
sha256?: string;
@@ -1014,6 +1019,8 @@ function BlobDetailView({
/** Full blob URL with extension */
blobUrl?: string;
blob?: BlobDescriptor;
/** Media type hint (image/video/audio) */
mediaType?: "image" | "video" | "audio";
onBack?: () => void;
}) {
const { copy, copied } = useCopy();
@@ -1049,9 +1056,19 @@ function BlobDetailView({
};
const urlMediaType = getMediaTypeFromUrl(blobUrl);
const isImage = mimeType?.startsWith("image/") || urlMediaType === "image";
const isVideo = mimeType?.startsWith("video/") || urlMediaType === "video";
const isAudio = mimeType?.startsWith("audio/") || urlMediaType === "audio";
// Priority: mimeType from blob > provided mediaType hint > detected from URL
const isImage =
mimeType?.startsWith("image/") ||
providedMediaType === "image" ||
urlMediaType === "image";
const isVideo =
mimeType?.startsWith("video/") ||
providedMediaType === "video" ||
urlMediaType === "video";
const isAudio =
mimeType?.startsWith("audio/") ||
providedMediaType === "audio" ||
urlMediaType === "audio";
if (!blobSha256) {
return (

View File

@@ -4,8 +4,8 @@
* Shows compact inline file info with expandable media:
* [icon] truncated-hash [blossom]
*
* Click on filename expands to show the actual media inline.
* Tooltip shows full filename and size.
* Click on filename expands to show the actual media inline (not collapsible).
* Tooltip shows imeta info when available.
*/
import { useState } from "react";
@@ -18,6 +18,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { MediaEmbed } from "@/components/nostr/MediaEmbed";
import type { MediaRendererProps } from "@/components/nostr/RichText";
/**
@@ -37,21 +38,6 @@ function getExtension(url: string): string | null {
}
}
/**
* Get display name for the file (for tooltip)
*/
function getFullFilename(url: string, alt?: string): string {
if (alt) return alt;
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const lastSegment = pathname.split("/").pop() || "";
return decodeURIComponent(lastSegment) || "file";
} catch {
return "file";
}
}
/**
* Get truncated hash display for compact view
*/
@@ -99,6 +85,15 @@ function parseBlossomUrl(
}
}
/**
* Format duration in seconds to human readable format
*/
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
}
/**
* Get icon component based on media type
*/
@@ -120,17 +115,15 @@ export function ChatMediaRenderer({ url, type, imeta }: MediaRendererProps) {
const { addWindow } = useGrimoire();
const [expanded, setExpanded] = useState(false);
const fullFilename = getFullFilename(url, imeta?.alt);
const truncatedHash = getTruncatedHash(url);
const size = imeta?.size ? formatFileSize(imeta.size) : null;
const blossom = parseBlossomUrl(url);
const handleBlossomClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (blossom) {
// Build command string for Edit functionality
const commandString = `blossom blob ${blossom.sha256} ${blossom.serverUrl}`;
// Build command string for Edit functionality - include media type
const commandString = `blossom blob ${blossom.sha256} ${blossom.serverUrl} --type ${type}`;
addWindow(
"blossom",
{
@@ -138,82 +131,83 @@ export function ChatMediaRenderer({ url, type, imeta }: MediaRendererProps) {
sha256: blossom.sha256,
serverUrl: blossom.serverUrl,
blobUrl: url, // Pass full URL with extension
mediaType: type, // Pass media type for preview
},
commandString,
);
}
};
const handleToggleExpand = (e: React.MouseEvent) => {
const handleExpand = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setExpanded(!expanded);
setExpanded(true);
};
// Build tooltip content
const tooltipContent = (
<div className="space-y-0.5">
<div className="font-medium">{fullFilename}</div>
{size && <div className="text-muted-foreground">{size}</div>}
</div>
);
// When expanded, show plain MediaEmbed (not collapsible)
if (expanded) {
return (
<span className="inline-flex flex-col gap-1">
{/* Collapsed toggle bar */}
<span className="inline-flex items-center gap-1 border-b border-dotted border-muted-foreground/50">
<MediaIcon type={type} />
<button
onClick={handleToggleExpand}
className="text-muted-foreground hover:text-foreground text-xs"
>
collapse
</button>
</span>
{/* Expanded media */}
<span className="block max-w-sm">
{type === "image" && (
<img
src={url}
alt={imeta?.alt || ""}
className="rounded max-w-full max-h-64 object-contain"
/>
)}
{type === "video" && (
<video src={url} controls className="rounded max-w-full max-h-64" />
)}
{type === "audio" && (
<audio src={url} controls className="w-full max-w-sm" />
)}
</span>
<span className="block max-w-sm my-1">
<MediaEmbed
url={url}
type={type}
alt={imeta?.alt}
preset="inline"
enableZoom={type === "image"}
/>
</span>
);
}
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center gap-1 border-b border-dotted border-muted-foreground/50">
<MediaIcon type={type} />
<button
onClick={handleToggleExpand}
className="text-foreground hover:underline"
>
{truncatedHash}
</button>
{blossom && (
<button
onClick={handleBlossomClick}
className="text-muted-foreground hover:text-foreground"
title="View in Blossom"
>
<HardDrive className="size-3" />
</button>
)}
</span>
</TooltipTrigger>
<TooltipContent>{tooltipContent}</TooltipContent>
</Tooltip>
// Build tooltip content from imeta if available
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>}
{imeta.size && (
<div className="text-muted-foreground">
{formatFileSize(imeta.size)}
</div>
)}
{imeta.duration && (
<div className="text-muted-foreground">
{formatDuration(imeta.duration)}
</div>
)}
</div>
) : null;
const compactView = (
<span className="inline-flex items-center gap-1 border-b border-dotted border-muted-foreground/50">
<MediaIcon type={type} />
<button
onClick={handleExpand}
className="text-foreground hover:underline"
>
{truncatedHash}
</button>
{blossom && (
<button
onClick={handleBlossomClick}
className="text-muted-foreground hover:text-foreground"
title="View in Blossom"
>
<HardDrive className="size-3" />
</button>
)}
</span>
);
// Only wrap in tooltip if we have imeta
if (tooltipContent) {
return (
<Tooltip>
<TooltipTrigger asChild>{compactView}</TooltipTrigger>
<TooltipContent>{tooltipContent}</TooltipContent>
</Tooltip>
);
}
return compactView;
}