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 ;
+ case "audio":
+ return ;
+ default:
+ return ;
+ }
+}
+
+export function CompactMediaRenderer({ url, type, imeta }: MediaRendererProps) {
+ const { addWindow } = useGrimoire();
+ const [expanded, setExpanded] = useState(false);
+
+ const truncatedHash = getTruncatedHash(url);
+ const blossom = parseBlossomUrl(url);
+
+ const handleBlossomClick = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (blossom) {
+ // Build command string for Edit functionality - include media type
+ const commandString = `blossom blob ${blossom.sha256} ${blossom.serverUrl} --type ${type}`;
+ addWindow(
+ "blossom",
+ {
+ subcommand: "blob",
+ sha256: blossom.sha256,
+ serverUrl: blossom.serverUrl,
+ blobUrl: url, // Pass full URL with extension
+ mediaType: type, // Pass media type for preview
+ },
+ commandString,
+ );
+ }
+ };
+
+ const handleExpand = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setExpanded(true);
+ };
+
+ // When expanded, show plain MediaEmbed (not collapsible)
+ if (expanded) {
+ return (
+
+
+
+ );
+ }
+
+ // Build tooltip content from imeta if available
+ // Format: "Field " with field name and value side by side
+ const tooltipContent = imeta ? (
+
+ {imeta.x && (
+
+ Hash
+ {imeta.x}
+
+ )}
+ {imeta.size && (
+
+ Size
+ {formatFileSize(imeta.size)}
+
+ )}
+ {imeta.dim && (
+
+ Dimensions
+ {imeta.dim}
+
+ )}
+ {imeta.m && (
+
+ Type
+ {imeta.m}
+
+ )}
+ {imeta.duration && (
+
+ Duration
+ {formatDuration(imeta.duration)}
+
+ )}
+ {imeta.alt && (
+
+ Alt
+ {imeta.alt}
+
+ )}
+
+ ) : null;
+
+ const compactView = (
+
+
+
+ {blossom && (
+
+ )}
+
+ );
+
+ // Only wrap in tooltip if we have imeta
+ if (tooltipContent) {
+ return (
+
+ {compactView}
+ {tooltipContent}
+
+ );
+ }
+
+ return compactView;
+}
diff --git a/src/components/nostr/RichText.tsx b/src/components/nostr/RichText.tsx
index d5487a3..74ec506 100644
--- a/src/components/nostr/RichText.tsx
+++ b/src/components/nostr/RichText.tsx
@@ -14,6 +14,24 @@ import { nipReferences } from "@/lib/nip-transformer";
import { relayReferences } from "@/lib/relay-transformer";
import type { NostrEvent } from "@/types/nostr";
import type { Root } from "applesauce-content/nast";
+import type { ImetaEntry } from "@/lib/imeta";
+
+/**
+ * Props for custom media renderers
+ */
+export interface MediaRendererProps {
+ url: string;
+ type: "image" | "video" | "audio";
+ /** Image/video metadata from imeta tags (NIP-92) if available */
+ imeta?: ImetaEntry;
+}
+
+// Context for passing the source event (for imeta lookup)
+const EventContext = createContext(null);
+
+export function useRichTextEvent() {
+ return useContext(EventContext);
+}
/** Transformer function type compatible with applesauce-content */
export type ContentTransformer = () => (tree: Root) => void;
@@ -162,13 +180,15 @@ export function RichText({
return (
-
- {children}
- {renderedContent}
-
+
+
+ {children}
+ {renderedContent}
+
+
);
diff --git a/src/components/nostr/RichText/Gallery.tsx b/src/components/nostr/RichText/Gallery.tsx
index 659d470..67addc4 100644
--- a/src/components/nostr/RichText/Gallery.tsx
+++ b/src/components/nostr/RichText/Gallery.tsx
@@ -6,7 +6,10 @@ import {
} from "applesauce-core/helpers/url";
import { MediaDialog } from "../MediaDialog";
import { MediaEmbed } from "../MediaEmbed";
-import { useRichTextOptions } from "../RichText";
+import { CompactMediaRenderer } from "../CompactMediaRenderer";
+import { useRichTextOptions, useRichTextEvent } from "../RichText";
+import { findImetaForUrl } from "@/lib/imeta";
+import { useSettings } from "@/hooks/useSettings";
function MediaPlaceholder({
type,
@@ -24,9 +27,14 @@ interface GalleryNodeProps {
export function Gallery({ node }: GalleryNodeProps) {
const options = useRichTextOptions();
+ const event = useRichTextEvent();
+ const { settings } = useSettings();
const [dialogOpen, setDialogOpen] = useState(false);
const [initialIndex, setInitialIndex] = useState(0);
+ // Check global loadMedia setting
+ const loadMedia = settings?.appearance?.loadMedia ?? true;
+
const links = node.links || [];
const handleAudioClick = (index: number) => {
@@ -38,20 +46,32 @@ export function Gallery({ node }: GalleryNodeProps) {
// Check if media should be shown
const shouldShowMedia = options.showMedia;
+ // Look up imeta for this URL if event is available
+ const imeta = event ? findImetaForUrl(event, url) : undefined;
+
if (isImageURL(url)) {
if (shouldShowMedia && options.showImages) {
+ if (!loadMedia) {
+ return ;
+ }
return ;
}
return ;
}
if (isVideoURL(url)) {
if (shouldShowMedia && options.showVideos) {
+ if (!loadMedia) {
+ return ;
+ }
return ;
}
return ;
}
if (isAudioURL(url)) {
if (shouldShowMedia && options.showAudio) {
+ if (!loadMedia) {
+ return ;
+ }
return (
[{type}];
@@ -21,9 +24,17 @@ interface LinkNodeProps {
export function Link({ node }: LinkNodeProps) {
const options = useRichTextOptions();
+ const event = useRichTextEvent();
+ const { settings } = useSettings();
const [dialogOpen, setDialogOpen] = useState(false);
const { href } = node;
+ // Check global loadMedia setting
+ const loadMedia = settings?.appearance?.loadMedia ?? true;
+
+ // Look up imeta for this URL if event is available
+ const imeta = event ? findImetaForUrl(event, href) : undefined;
+
const handleAudioClick = () => {
setDialogOpen(true);
};
@@ -34,6 +45,9 @@ export function Link({ node }: LinkNodeProps) {
// Render appropriate link type
if (isImageURL(href)) {
if (shouldShowMedia && options.showImages) {
+ if (!loadMedia) {
+ return ;
+ }
return (
;
+ }
return (
;
+ }
return (
<>
[server]",
+ "SHA256 hash required. Usage: blossom blob [server] [--type image|video|audio]",
);
}
const sha256 = args[1].toLowerCase();
if (!/^[0-9a-f]{64}$/.test(sha256)) {
throw new Error("Invalid SHA256 hash. Must be 64 hex characters.");
}
+
+ // Parse remaining args for server and --type flag
+ let serverUrl: string | undefined;
+ let mediaType: "image" | "video" | "audio" | undefined;
+
+ for (let i = 2; i < args.length; i++) {
+ if (args[i] === "--type" && args[i + 1]) {
+ const typeArg = args[i + 1].toLowerCase();
+ if (
+ typeArg === "image" ||
+ typeArg === "video" ||
+ typeArg === "audio"
+ ) {
+ mediaType = typeArg;
+ }
+ i++; // Skip the type value
+ } else if (!args[i].startsWith("--") && !serverUrl) {
+ serverUrl = normalizeServerUrl(args[i]);
+ }
+ }
+
return {
subcommand: "blob",
sha256,
- serverUrl: args[2] ? normalizeServerUrl(args[2]) : undefined,
+ serverUrl,
+ mediaType,
};
}
diff --git a/src/lib/imeta.ts b/src/lib/imeta.ts
index 526fdfe..2d26ffe 100644
--- a/src/lib/imeta.ts
+++ b/src/lib/imeta.ts
@@ -64,6 +64,17 @@ export function parseImetaTags(event: NostrEvent): ImetaEntry[] {
.filter((entry): entry is ImetaEntry => entry !== null);
}
+/**
+ * Find imeta entry for a specific URL
+ */
+export function findImetaForUrl(
+ event: NostrEvent,
+ url: string,
+): ImetaEntry | undefined {
+ const entries = parseImetaTags(event);
+ return entries.find((entry) => entry.url === url);
+}
+
/**
* Parse file metadata from NIP-94 kind 1063 event tags
*/
diff --git a/src/services/settings.ts b/src/services/settings.ts
index 4effa8f..bc798c1 100644
--- a/src/services/settings.ts
+++ b/src/services/settings.ts
@@ -22,6 +22,8 @@ export interface PostSettings {
export interface AppearanceSettings {
/** Show client tags in event UI */
showClientTags: boolean;
+ /** Load media inline (images, videos, audio) - when false, show compact links */
+ loadMedia: boolean;
}
/**
@@ -43,6 +45,7 @@ const DEFAULT_POST_SETTINGS: PostSettings = {
const DEFAULT_APPEARANCE_SETTINGS: AppearanceSettings = {
showClientTags: true,
+ loadMedia: true,
};
export const DEFAULT_SETTINGS: AppSettings = {