mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 08:27:27 +02:00
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:
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user