feat: improve ChatMediaRenderer with expandable inline media

- More compact representation: "icon truncated-hash blossom"
- Click filename to expand and show actual media inline (image/video/audio)
- Tooltip shows full filename and file size on hover
- Audio support with inline audio player when expanded
- Fixed BlossomViewer to detect media type from URL extension when
  mimeType is not available from blob descriptor

https://claude.ai/code/session_01AeeN5d5EcVLGjZbGueZxaD
This commit is contained in:
Claude
2026-01-30 13:23:44 +00:00
parent abf786ac35
commit ff9f3a747c
2 changed files with 137 additions and 42 deletions

View File

@@ -1027,9 +1027,31 @@ function BlobDetailView({
const blobSha256 = blob?.sha256 || sha256;
const mimeType = blob?.type;
const isImage = mimeType?.startsWith("image/");
const isVideo = mimeType?.startsWith("video/");
const isAudio = mimeType?.startsWith("audio/");
// Detect media type from URL extension if mimeType not available
const getMediaTypeFromUrl = (
url: string | null,
): "image" | "video" | "audio" | null => {
if (!url) return null;
try {
const pathname = new URL(url).pathname.toLowerCase();
const ext = pathname.split(".").pop();
if (!ext) return null;
const imageExts = ["jpg", "jpeg", "png", "gif", "webp", "svg", "avif"];
const videoExts = ["mp4", "webm", "mov", "avi", "mkv", "m4v"];
const audioExts = ["mp3", "wav", "ogg", "flac", "m4a", "aac"];
if (imageExts.includes(ext)) return "image";
if (videoExts.includes(ext)) return "video";
if (audioExts.includes(ext)) return "audio";
return null;
} catch {
return null;
}
};
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";
if (!blobSha256) {
return (

View File

@@ -1,16 +1,23 @@
/**
* Chat-specific media renderer
*
* Shows inline file info instead of embedded media:
* [icon] filename [size] [blossom-link]
* Shows compact inline file info with expandable media:
* [icon] truncated-hash [blossom]
*
* Click on filename opens media in new tab.
* Click on filename expands to show the actual media inline.
* Tooltip shows full filename and size.
*/
import { useState } from "react";
import { Image, Video, Music, File, HardDrive } from "lucide-react";
import { getHashFromURL } from "blossom-client-sdk/helpers/url";
import { useGrimoire } from "@/core/state";
import { formatFileSize } from "@/lib/imeta";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { MediaRendererProps } from "@/components/nostr/RichText";
/**
@@ -31,26 +38,49 @@ function getExtension(url: string): string | null {
}
/**
* Extract filename from URL
* Get display name for the file (for tooltip)
*/
function getFilename(url: string): string {
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() || "";
// If it's a blossom hash, truncate it
const hash = getHashFromURL(url);
if (hash) {
const ext = getExtension(url);
return ext ? `${hash.slice(0, 8)}...${ext}` : `${hash.slice(0, 8)}...`;
}
// Decode URI component for readable filenames
return decodeURIComponent(lastSegment) || "file";
} catch {
return "file";
}
}
/**
* Get truncated hash display for compact view
*/
function getTruncatedHash(url: string): string {
const hash = getHashFromURL(url);
if (hash) {
const ext = getExtension(url);
// Show first 6 chars of hash
return ext ? `${hash.slice(0, 6)}${ext}` : `${hash.slice(0, 6)}`;
}
// Fallback: truncate filename
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const lastSegment = decodeURIComponent(pathname.split("/").pop() || "file");
if (lastSegment.length > 12) {
const ext = getExtension(url);
if (ext) {
const nameWithoutExt = lastSegment.slice(0, -(ext.length + 1));
return `${nameWithoutExt.slice(0, 6)}${ext}`;
}
return `${lastSegment.slice(0, 8)}`;
}
return lastSegment;
} catch {
return "file";
}
}
/**
* Parse blossom URL - returns sha256 and server URL if valid
*/
@@ -88,8 +118,10 @@ function MediaIcon({ type }: { type: "image" | "video" | "audio" }) {
export function ChatMediaRenderer({ url, type, imeta }: MediaRendererProps) {
const { addWindow } = useGrimoire();
const [expanded, setExpanded] = useState(false);
const filename = imeta?.alt || getFilename(url);
const fullFilename = getFullFilename(url, imeta?.alt);
const truncatedHash = getTruncatedHash(url);
const size = imeta?.size ? formatFileSize(imeta.size) : null;
const blossom = parseBlossomUrl(url);
@@ -112,35 +144,76 @@ export function ChatMediaRenderer({ url, type, imeta }: MediaRendererProps) {
}
};
const handleMediaClick = (e: React.MouseEvent) => {
const handleToggleExpand = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setExpanded(!expanded);
};
// 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>
);
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>
);
}
return (
<span className="inline-flex items-center gap-1 border-b border-dotted border-muted-foreground/50">
<MediaIcon type={type} />
<a
href={url}
target="_blank"
rel="noopener noreferrer"
onClick={handleMediaClick}
className="text-foreground hover:underline truncate max-w-48"
title={imeta?.alt || url}
>
{filename}
</a>
{size && (
<span className="text-muted-foreground text-xs shrink-0">{size}</span>
)}
{blossom && (
<button
onClick={handleBlossomClick}
className="text-muted-foreground hover:text-foreground"
title="View in Blossom"
>
<HardDrive className="size-3" />
</button>
)}
</span>
<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>
);
}