mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-04 17:51:12 +02:00
feat: load media setting (#230)
* feat: add custom media renderer support to RichText Add renderMedia prop to RichText component that allows overriding how images, videos, and audio are rendered across the entire RichText subtree (including Link and Gallery components). The custom renderer receives: - url: the media URL - type: "image" | "video" | "audio" - imeta: optional NIP-92 metadata (dimensions, blurhash, alt, etc.) This enables chat-specific media rendering with compact thumbnails, lightboxes, or any custom presentation. https://claude.ai/code/session_01AeeN5d5EcVLGjZbGueZxaD * feat: add ChatMediaRenderer for compact inline media display Custom media renderer for chat that shows: - Media type icon (image/video/audio) - Filename (extracted from URL or imeta alt) - File size (from imeta if available) - Blossom link button (opens blob viewer for blossom URLs) Usage: <RichText event={message} renderMedia={ChatMediaRenderer} /> https://claude.ai/code/session_01AeeN5d5EcVLGjZbGueZxaD * feat: use ChatMediaRenderer in chat messages - Updated ChatMediaRenderer to use getHashFromURL from blossom-client-sdk - Integrated ChatMediaRenderer into ChatViewer for message content https://claude.ai/code/session_01AeeN5d5EcVLGjZbGueZxaD * style: make ChatMediaRenderer inline with subtle dotted border Use a subtle dotted bottom border instead of background/padding to better integrate with surrounding text flow. https://claude.ai/code/session_01AeeN5d5EcVLGjZbGueZxaD * feat: improve ChatMediaRenderer UX - Full dotted border with rounded corners (was bottom-only) - Increased gap between elements - Images/videos open in MediaDialog zoom viewer on click - Audio still opens in new tab - Better extension extraction for display https://claude.ai/code/session_01AeeN5d5EcVLGjZbGueZxaD * feat: improve blossom integration in ChatMediaRenderer - Use HardDrive icon instead of Flower2 (consistent with rest of app) - Add commandString for Edit functionality in window toolbar - Pass blobUrl with extension to BlossomViewer for correct media preview - Extended BlossomViewer.BlobDetailView to accept blobUrl prop https://claude.ai/code/session_01AeeN5d5EcVLGjZbGueZxaD * refactor: simplify ChatMediaRenderer styling - All icons now size-3 - Use dotted underline only (cleaner inline appearance) - All media types open in new tab on click - Removed MediaDialog dependency https://claude.ai/code/session_01AeeN5d5EcVLGjZbGueZxaD * fix: inherit custom media renderer in nested RichText components Previously, nested RichText components (like those in QuotedEvent/KindRenderer) would always reset the media renderer context to null because the provider used `renderMedia ?? null`. This caused embedded events in chat to use the default media renderer instead of ChatMediaRenderer. Now RichText inherits the parent media renderer when no explicit renderMedia prop is passed, allowing custom renderers to work throughout the entire rich text subtree including quoted events. https://claude.ai/code/session_01AeeN5d5EcVLGjZbGueZxaD * 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 * 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 * fix: blossom blob command now parses --type flag - Added mediaType and blobUrl fields to BlossomCommandResult - Blob subcommand now parses --type image|video|audio flag - Fixed server URL overflow in blob detail view with truncation https://claude.ai/code/session_01AeeN5d5EcVLGjZbGueZxaD * refactor: add global loadMedia setting with CompactMediaRenderer - Add `loadMedia` setting to Appearance section (enabled by default) - Create CompactMediaRenderer in src/components/nostr/ (renamed from ChatMediaRenderer) - Link.tsx and Gallery.tsx now check the setting and use CompactMediaRenderer when disabled - Tooltip format improved: "Field <value>" with field name and value side by side - Shows: Hash, Size, Dimensions, Type, Duration, Alt - Remove renderMedia prop from ChatViewer (now automatic based on setting) - Delete old ChatMediaRenderer.tsx This makes media rendering a site-wide setting rather than chat-specific. https://claude.ai/code/session_01AeeN5d5EcVLGjZbGueZxaD --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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 <ListBlobsView pubkey={pubkey} serverUrl={serverUrl} />;
|
||||
case "blob":
|
||||
return <BlobDetailView sha256={sha256!} serverUrl={serverUrl} />;
|
||||
return (
|
||||
<BlobDetailView
|
||||
sha256={sha256!}
|
||||
serverUrl={serverUrl}
|
||||
blobUrl={blobUrl}
|
||||
mediaType={mediaType}
|
||||
/>
|
||||
);
|
||||
case "mirror":
|
||||
return <MirrorView sourceUrl={sourceUrl!} targetServer={targetServer!} />;
|
||||
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
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="size-4 text-muted-foreground" />
|
||||
<code className="text-xs">{serverUrl}</code>
|
||||
<HardDrive className="size-4 text-muted-foreground shrink-0" />
|
||||
<code className="text-xs truncate" title={serverUrl}>
|
||||
{serverUrl}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -87,6 +87,27 @@ export function SettingsViewer() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<label
|
||||
htmlFor="load-media"
|
||||
className="text-base font-medium cursor-pointer"
|
||||
>
|
||||
Load media
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Render links to media as inline images, videos, and audio
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="load-media"
|
||||
checked={settings?.appearance?.loadMedia ?? true}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
updateSetting("appearance", "loadMedia", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
237
src/components/nostr/CompactMediaRenderer.tsx
Normal file
237
src/components/nostr/CompactMediaRenderer.tsx
Normal file
@@ -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 <Image className={iconClass} />;
|
||||
case "video":
|
||||
return <Video className={iconClass} />;
|
||||
case "audio":
|
||||
return <Music className={iconClass} />;
|
||||
default:
|
||||
return <File className={iconClass} />;
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<span className="block max-w-sm my-1">
|
||||
<MediaEmbed
|
||||
url={url}
|
||||
type={type}
|
||||
alt={imeta?.alt}
|
||||
preset="inline"
|
||||
enableZoom={type === "image"}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Build tooltip content from imeta if available
|
||||
// Format: "Field <value>" with field name and value side by side
|
||||
const tooltipContent = imeta ? (
|
||||
<div className="space-y-1 text-xs">
|
||||
{imeta.x && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted-foreground shrink-0">Hash</span>
|
||||
<span className="font-mono truncate max-w-48">{imeta.x}</span>
|
||||
</div>
|
||||
)}
|
||||
{imeta.size && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted-foreground shrink-0">Size</span>
|
||||
<span>{formatFileSize(imeta.size)}</span>
|
||||
</div>
|
||||
)}
|
||||
{imeta.dim && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted-foreground shrink-0">Dimensions</span>
|
||||
<span>{imeta.dim}</span>
|
||||
</div>
|
||||
)}
|
||||
{imeta.m && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted-foreground shrink-0">Type</span>
|
||||
<span>{imeta.m}</span>
|
||||
</div>
|
||||
)}
|
||||
{imeta.duration && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted-foreground shrink-0">Duration</span>
|
||||
<span>{formatDuration(imeta.duration)}</span>
|
||||
</div>
|
||||
)}
|
||||
{imeta.alt && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted-foreground shrink-0">Alt</span>
|
||||
<span className="truncate max-w-48">{imeta.alt}</span>
|
||||
</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;
|
||||
}
|
||||
@@ -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<NostrEvent | null>(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 (
|
||||
<DepthContext.Provider value={depth}>
|
||||
<OptionsContext.Provider value={mergedOptions}>
|
||||
<div
|
||||
dir="auto"
|
||||
className={cn("leading-relaxed break-words", className)}
|
||||
>
|
||||
{children}
|
||||
{renderedContent}
|
||||
</div>
|
||||
<EventContext.Provider value={event ?? null}>
|
||||
<div
|
||||
dir="auto"
|
||||
className={cn("leading-relaxed break-words", className)}
|
||||
>
|
||||
{children}
|
||||
{renderedContent}
|
||||
</div>
|
||||
</EventContext.Provider>
|
||||
</OptionsContext.Provider>
|
||||
</DepthContext.Provider>
|
||||
);
|
||||
|
||||
@@ -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 <CompactMediaRenderer url={url} type="image" imeta={imeta} />;
|
||||
}
|
||||
return <MediaEmbed url={url} type="image" preset="grid" enableZoom />;
|
||||
}
|
||||
return <MediaPlaceholder type="image" />;
|
||||
}
|
||||
if (isVideoURL(url)) {
|
||||
if (shouldShowMedia && options.showVideos) {
|
||||
if (!loadMedia) {
|
||||
return <CompactMediaRenderer url={url} type="video" imeta={imeta} />;
|
||||
}
|
||||
return <MediaEmbed url={url} type="video" preset="grid" />;
|
||||
}
|
||||
return <MediaPlaceholder type="video" />;
|
||||
}
|
||||
if (isAudioURL(url)) {
|
||||
if (shouldShowMedia && options.showAudio) {
|
||||
if (!loadMedia) {
|
||||
return <CompactMediaRenderer url={url} type="audio" imeta={imeta} />;
|
||||
}
|
||||
return (
|
||||
<MediaEmbed
|
||||
url={url}
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
import { MediaDialog } from "../MediaDialog";
|
||||
import { MediaEmbed } from "../MediaEmbed";
|
||||
import { PlainLink } from "../LinkPreview";
|
||||
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 }: { type: "image" | "video" | "audio" }) {
|
||||
return <span className="text-muted-foreground">[{type}]</span>;
|
||||
@@ -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 <CompactMediaRenderer url={href} type="image" imeta={imeta} />;
|
||||
}
|
||||
return (
|
||||
<MediaEmbed
|
||||
url={href}
|
||||
@@ -49,6 +63,9 @@ export function Link({ node }: LinkNodeProps) {
|
||||
|
||||
if (isVideoURL(href)) {
|
||||
if (shouldShowMedia && options.showVideos) {
|
||||
if (!loadMedia) {
|
||||
return <CompactMediaRenderer url={href} type="video" imeta={imeta} />;
|
||||
}
|
||||
return (
|
||||
<MediaEmbed
|
||||
url={href}
|
||||
@@ -63,6 +80,9 @@ export function Link({ node }: LinkNodeProps) {
|
||||
|
||||
if (isAudioURL(href)) {
|
||||
if (shouldShowMedia && options.showAudio) {
|
||||
if (!loadMedia) {
|
||||
return <CompactMediaRenderer url={href} type="audio" imeta={imeta} />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<MediaEmbed
|
||||
|
||||
@@ -34,6 +34,10 @@ export interface BlossomCommandResult {
|
||||
// For 'mirror' subcommand
|
||||
sourceUrl?: string;
|
||||
targetServer?: string;
|
||||
// For 'blob' subcommand - media type hint for preview
|
||||
mediaType?: "image" | "video" | "audio";
|
||||
// For 'blob' subcommand - full blob URL with extension
|
||||
blobUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,17 +170,39 @@ export async function parseBlossomCommand(
|
||||
case "view": {
|
||||
if (args.length < 2) {
|
||||
throw new Error(
|
||||
"SHA256 hash required. Usage: blossom blob <sha256> [server]",
|
||||
"SHA256 hash required. Usage: blossom blob <sha256> [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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user