mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-05 18:21:28 +02:00
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:
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user