mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 07:27:23 +02:00
* 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>
212 lines
5.2 KiB
TypeScript
212 lines
5.2 KiB
TypeScript
/**
|
|
* Utilities for parsing imeta tags (NIP-92) and file metadata tags (NIP-94)
|
|
*/
|
|
|
|
import type { NostrEvent } from "@/types/nostr";
|
|
|
|
export interface ImetaEntry {
|
|
url: string;
|
|
m?: string; // MIME type
|
|
blurhash?: string;
|
|
dim?: string; // dimensions (e.g., "1920x1080")
|
|
alt?: string; // alt text
|
|
x?: string; // SHA-256 hash
|
|
size?: string; // file size in bytes
|
|
fallback?: string[]; // fallback URLs
|
|
duration?: number; // audio/video duration in seconds (NIP-A0)
|
|
}
|
|
|
|
/**
|
|
* Parse an imeta tag into structured data
|
|
* Format: ["imeta", "url https://...", "m image/jpeg", "blurhash U...]
|
|
*/
|
|
export function parseImetaTag(tag: string[]): ImetaEntry | null {
|
|
if (tag[0] !== "imeta" || tag.length < 2) return null;
|
|
|
|
const entry: Partial<ImetaEntry> = {};
|
|
|
|
// Parse each key-value pair
|
|
for (let i = 1; i < tag.length; i++) {
|
|
const parts = tag[i].split(" ");
|
|
if (parts.length < 2) continue;
|
|
|
|
const key = parts[0];
|
|
const value = parts.slice(1).join(" ");
|
|
|
|
if (key === "url") {
|
|
entry.url = value;
|
|
} else if (key === "fallback") {
|
|
if (!entry.fallback) entry.fallback = [];
|
|
entry.fallback.push(value);
|
|
} else if (key === "duration") {
|
|
const parsed = parseFloat(value);
|
|
if (!isNaN(parsed)) {
|
|
entry.duration = parsed;
|
|
}
|
|
} else {
|
|
(entry as any)[key] = value;
|
|
}
|
|
}
|
|
|
|
// URL is required
|
|
if (!entry.url) return null;
|
|
|
|
return entry as ImetaEntry;
|
|
}
|
|
|
|
/**
|
|
* Parse all imeta tags from an event
|
|
*/
|
|
export function parseImetaTags(event: NostrEvent): ImetaEntry[] {
|
|
return event.tags
|
|
.filter((tag) => tag[0] === "imeta")
|
|
.map(parseImetaTag)
|
|
.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
|
|
*/
|
|
export function parseFileMetadata(event: NostrEvent): ImetaEntry {
|
|
const metadata: Partial<ImetaEntry> = {};
|
|
|
|
for (const tag of event.tags) {
|
|
const [key, value] = tag;
|
|
if (!value) continue;
|
|
|
|
switch (key) {
|
|
case "url":
|
|
metadata.url = value;
|
|
break;
|
|
case "m":
|
|
metadata.m = value;
|
|
break;
|
|
case "x":
|
|
metadata.x = value;
|
|
break;
|
|
case "size":
|
|
metadata.size = value;
|
|
break;
|
|
case "dim":
|
|
metadata.dim = value;
|
|
break;
|
|
case "blurhash":
|
|
metadata.blurhash = value;
|
|
break;
|
|
case "alt":
|
|
metadata.alt = value;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return metadata as ImetaEntry;
|
|
}
|
|
|
|
/**
|
|
* Get the primary image URL from a picture event (kind 20)
|
|
* Tries imeta tags first, then falls back to content
|
|
*/
|
|
export function getPictureUrl(event: NostrEvent): string | null {
|
|
// Try imeta tags first
|
|
const imeta = parseImetaTags(event);
|
|
if (imeta.length > 0 && imeta[0].url) {
|
|
return imeta[0].url;
|
|
}
|
|
|
|
// Fallback: try to extract URL from content
|
|
const urlMatch = event.content.match(/https?:\/\/[^\s]+/);
|
|
return urlMatch ? urlMatch[0] : null;
|
|
}
|
|
|
|
/**
|
|
* Check if a MIME type is an image
|
|
*/
|
|
export function isImageMime(mime?: string): boolean {
|
|
if (!mime) return false;
|
|
return mime.startsWith("image/");
|
|
}
|
|
|
|
/**
|
|
* Check if a MIME type is a video
|
|
*/
|
|
export function isVideoMime(mime?: string): boolean {
|
|
if (!mime) return false;
|
|
return mime.startsWith("video/");
|
|
}
|
|
|
|
/**
|
|
* Check if a MIME type is audio
|
|
*/
|
|
export function isAudioMime(mime?: string): boolean {
|
|
if (!mime) return false;
|
|
return mime.startsWith("audio/");
|
|
}
|
|
|
|
/**
|
|
* Format duration in seconds to MM:SS or H:MM:SS format
|
|
*/
|
|
export function formatDuration(seconds?: number): string | null {
|
|
if (seconds === undefined || seconds < 0) return null;
|
|
|
|
const hrs = Math.floor(seconds / 3600);
|
|
const mins = Math.floor((seconds % 3600) / 60);
|
|
const secs = Math.floor(seconds % 60);
|
|
|
|
if (hrs > 0) {
|
|
return `${hrs}:${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
|
|
}
|
|
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
|
}
|
|
|
|
/**
|
|
* Format file size for display
|
|
*/
|
|
export function formatFileSize(bytes?: string | number): string {
|
|
if (!bytes) return "Unknown size";
|
|
|
|
const size = typeof bytes === "string" ? parseInt(bytes, 10) : bytes;
|
|
if (isNaN(size)) return "Unknown size";
|
|
|
|
const units = ["B", "KB", "MB", "GB"];
|
|
let unitIndex = 0;
|
|
let displaySize = size;
|
|
|
|
while (displaySize >= 1024 && unitIndex < units.length - 1) {
|
|
displaySize /= 1024;
|
|
unitIndex++;
|
|
}
|
|
|
|
return `${displaySize.toFixed(1)} ${units[unitIndex]}`;
|
|
}
|
|
|
|
/**
|
|
* Extract aspect ratio from imeta dimensions string
|
|
* @param dim - dimensions string like "1920x1080"
|
|
* @returns aspect ratio as string like "16/9" or undefined if invalid
|
|
*/
|
|
export function getAspectRatioFromDimensions(dim?: string): string | undefined {
|
|
if (!dim) return undefined;
|
|
|
|
const match = dim.match(/^(\d+)x(\d+)$/);
|
|
if (!match) return undefined;
|
|
|
|
const width = parseInt(match[1], 10);
|
|
const height = parseInt(match[2], 10);
|
|
|
|
if (width <= 0 || height <= 0) return undefined;
|
|
|
|
// Return as CSS aspect-ratio value
|
|
return `${width}/${height}`;
|
|
}
|