diff --git a/src/components/BlossomViewer.tsx b/src/components/BlossomViewer.tsx index 05d17a1..c39683b 100644 --- a/src/components/BlossomViewer.tsx +++ b/src/components/BlossomViewer.tsx @@ -51,6 +51,10 @@ interface BlossomViewerProps { sourceUrl?: string; targetServer?: string; sha256?: string; + /** Full blob URL with extension (for blob subcommand) */ + blobUrl?: string; + /** Media type hint for preview (image/video/audio) */ + mediaType?: "image" | "video" | "audio"; } /** @@ -63,6 +67,8 @@ export function BlossomViewer({ sourceUrl, targetServer, sha256, + blobUrl, + mediaType, }: BlossomViewerProps) { switch (subcommand) { case "servers": @@ -74,7 +80,14 @@ export function BlossomViewer({ case "list": return ; case "blob": - return ; + return ( + + ); case "mirror": return ; case "delete": @@ -996,26 +1009,66 @@ function BlobRow({ function BlobDetailView({ sha256, serverUrl, + blobUrl: providedBlobUrl, blob: initialBlob, + mediaType: providedMediaType, onBack, }: { sha256?: string; serverUrl?: string; + /** 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(); const blob = initialBlob; - // If we have a blob descriptor, use that data + // Use provided URL, or blob descriptor URL, or construct from server + sha256 const blobUrl = - blob?.url || (serverUrl && sha256 ? `${serverUrl}/${sha256}` : null); + providedBlobUrl || + blob?.url || + (serverUrl && sha256 ? `${serverUrl}/${sha256}` : null); 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); + // 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 ( @@ -1133,8 +1186,10 @@ function BlobDetailView({ Server
- - {serverUrl} + + + {serverUrl} +
)} diff --git a/src/components/SettingsViewer.tsx b/src/components/SettingsViewer.tsx index cc4eb85..ef596e4 100644 --- a/src/components/SettingsViewer.tsx +++ b/src/components/SettingsViewer.tsx @@ -87,6 +87,27 @@ export function SettingsViewer() { } /> + +
+
+ +

+ Render links to media as inline images, videos, and audio +

+
+ + updateSetting("appearance", "loadMedia", checked) + } + /> +
diff --git a/src/components/nostr/CompactMediaRenderer.tsx b/src/components/nostr/CompactMediaRenderer.tsx new file mode 100644 index 0000000..8fc04ae --- /dev/null +++ b/src/components/nostr/CompactMediaRenderer.tsx @@ -0,0 +1,237 @@ +/** + * Compact media renderer for RichText + * + * Shows compact inline file info with expandable media: + * [icon] truncated-hash [blossom] + * + * Click on filename expands to show the actual media inline (not collapsible). + * Tooltip shows imeta info when available. + */ + +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 { MediaEmbed } from "@/components/nostr/MediaEmbed"; +import type { MediaRendererProps } from "@/components/nostr/RichText"; + +/** + * Extract file extension from URL + */ +function getExtension(url: string): string | null { + try { + const urlObj = new URL(url); + const pathname = urlObj.pathname; + const lastSegment = pathname.split("/").pop() || ""; + if (lastSegment.includes(".")) { + return lastSegment.split(".").pop() || null; + } + return null; + } catch { + return null; + } +} + +/** + * 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 + */ +function parseBlossomUrl( + url: string, +): { sha256: string; serverUrl: string } | null { + const sha256 = getHashFromURL(url); + if (!sha256) return null; + + try { + const urlObj = new URL(url); + const serverUrl = `${urlObj.protocol}//${urlObj.host}`; + return { sha256, serverUrl }; + } catch { + return null; + } +} + +/** + * 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 + */ +function MediaIcon({ type }: { type: "image" | "video" | "audio" }) { + const iconClass = "size-3 shrink-0 text-muted-foreground"; + switch (type) { + case "image": + return ; + case "video": + return